Compare commits

...
Author SHA1 Message Date
Keegan George a84e253c5b Revert "FEATURE: smooth streaming animation for summarization (#778)"
This reverts commit fdadfa029e.
2024-08-29 15:24:22 -07:00
Keegan George fdadfa029e FEATURE: smooth streaming animation for summarization (#778) 2024-08-29 15:07:07 -07:00
Keegan George 94f6c632bf DEV: Publish AI Bot PM title update to message bus channel (#781) 2024-08-29 14:48:44 -07:00
Sam 41054c4fb8 FEATURE: improve site setting search (#780)
This improves the site setting search so it performs a somewhat
fuzzy match.

Previously it did not handle seperators such as "space" and a
term such as "min_post_length" would not find "min_first_post_length"

A more liberal search algorithm makes it easier to the AI to
navigate settings.

* Minor fix, {{and parameter.enum parameter.enum.length}} is non
obviously broken.


If parameter.enum is a tracked array it will return the object
cause embers and helper implementation.

This corrects an issue where enum keeps on selecting itself by
mistake.
2024-08-29 16:05:38 +10:00
Keegan George 943504049c FIX: Prevent proofreading when there is no content (#779) 2024-08-28 12:21:34 -07:00
Rafael dos Santos Silva a08d168740 FEATURE: Initial support for seeded LLMs (#756) 2024-08-28 15:57:58 -03:00
Sam 0687ec75c3 FEATURE: allow embedding based search without hyde (#777)
This allows callers of embedding based search to bypass hyde.

Hyde will expand the search term using an LLM, but if an LLM is
performing the search we can skip this expansion.

It also introduced some tests for the controller which we did not have
2024-08-28 14:17:34 +10:00
Roman Rizzi 72607c3560 Revert "FEATURE: Triage rule can skip posts created via email (#775)" (#776)
This reverts commit 6af415f7f0.
2024-08-27 15:45:55 -03:00
Roman Rizzi 6af415f7f0 FEATURE: Triage rule can skip posts created via email (#775) 2024-08-27 12:04:55 -03:00
Jarek Radosz 1b209f5fb1 DEV: Update ace-editor usage (#773)
AceEditor is now a glimmer component (see: https://github.com/discourse/discourse/pull/28492) and it follows the "data down, actions up" pattern.
2024-08-27 13:52:55 +02:00
Keegan Georgeandchapoi 50c2d56aff UX: Topic summary UI improvements due to DMenu changes (#772)
Co-authored-by: chapoi <[email protected]>
2024-08-26 08:32:39 -07:00
SamandMartin Brennan f148452f4c FEATURE: single click proofreading (#769)
Previously there was too much work proofreading text, new implementation
provides a single shortcut and easy way of proofreading text.


Co-authored-by: Martin Brennan <[email protected]>
2024-08-26 15:43:40 +10:00
Roman Rizzi c6aeabbfc0 FIX: Malformed message in systemless + inline img scenario (#771) 2024-08-23 16:41:57 -03:00
Roman Rizzi eac83eb619 FIX: Triage's search_for_text should be case-insensitive (#767) 2024-08-22 18:32:42 -03:00
Roman Rizzi 9019e90b87 FIX: urlEditable must be true for all providers except Bedrock (#766) 2024-08-22 11:31:28 -03:00
David Taylor 3c7bd9bbd3 FIX: Tool editing code editor resetting on every keypress (#765)
`withEventValue` is not needed here, because the `onChange` event comes from ace, not a normal DOM event. But even with that fix, it seems AceEditor doesn't yet work well with the DDAU pattern. On every keypress, the editor re-renders and puts the cursor back at the beginning.

For now, this commit removes the `@onChange` hook, so we go back to relying on the two-way binding of `@content`.

Followup to a5a39dd2ee
2024-08-22 10:18:16 +01:00
chapoi 324af23b72 UX: add dark-light mixin for better visibility in dark themes (#764) 2024-08-22 01:24:18 +02:00
Keegan George da9e192949 FIX: Mobile composer helper trigger should work on Android and iOS (#763) 2024-08-21 16:19:25 -07:00
chapoiandKeegan George 10dae65740 UX: style & position AI helper in composer (#758)
Co-authored-by: Keegan George <[email protected]>
2024-08-21 11:01:03 -07:00
Roman Rizzi 64641b6175 FEATURE: LLM Triage support for systemless models. (#757)
* FEATURE: LLM Triage support for systemless models.

This change adds support for OSS models without support for system messages. LlmTriage's system message field is no longer mandatory. We now send the post contents in a separate user message.

* Models using Ollama can also disable system prompts
2024-08-21 11:41:55 -03:00
Sam 97fc822cb6 FEATURE: Allow specific groups access to summary feature on PMs (#760)
New `ai_pm_summarization_allowed_groups` can be used to allow
visibility of the summarization feature on PMs.

This can be useful on forums where a lot of communication happens
inside PMs.
2024-08-21 07:58:24 +10:00
Keegan George c0328267be FIX: Add original upload markdown on caption failure (#762) 2024-08-20 12:53:51 -07:00
Discourse Translator Bot dd63ade348 Update translations (#761) 2024-08-20 10:58:36 -03:00
Keegan George 4a40531143 FIX: Clicking on summary links breaks summary (#759) 2024-08-20 10:57:23 -03:00
Keegan George bfe3b1c3b8 FIX: Modals in composer helper menu not working (#755) 2024-08-16 10:08:58 -07:00
Roman Rizzi f789d3ee96 FIX: Triage-flagged posts didn't have a score. (#752)
The score will contain the LLM result, and make sure the flag isn't displayed when a minimum score threshold is present.
2024-08-14 15:54:09 -03:00
Keegan George 867cd54556 DEV: Reduce megapixel requirement for auto image captioning (#754) 2024-08-14 11:26:56 -07:00
Keegan George 23b88537d9 FIX: Prevent AI caption setting from showing unless all criteria is met (#753) 2024-08-14 10:17:36 -07:00
57 changed files with 1378 additions and 448 deletions
@@ -4,7 +4,9 @@ export default DiscourseRoute.extend({
async model(params) {
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
const id = parseInt(params.id, 10);
return allLlms.findBy("id", id);
const record = allLlms.findBy("id", id);
record.provider_params = record.provider_params || {};
return record;
},
setupController(controller, model) {
@@ -45,6 +45,10 @@ module DiscourseAi
def update
llm_model = LlmModel.find(params[:id])
if llm_model.seeded?
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
end
if llm_model.update(ai_llm_params(updating: llm_model))
llm_model.toggle_companion_user
render json: LlmModelSerializer.new(llm_model)
@@ -56,6 +60,10 @@ module DiscourseAi
def destroy
llm_model = LlmModel.find(params[:id])
if llm_model.seeded?
return render_json_error(I18n.t("discourse_ai.llm.cannot_delete_builtin"), status: 403)
end
in_use_by = DiscourseAi::Configuration::LlmValidator.new.modules_using(llm_model)
if !in_use_by.empty?
@@ -117,11 +125,21 @@ module DiscourseAi
new_url = params.dig(:ai_llm, :url)
permitted[:url] = new_url if permit_url && new_url
extra_field_names = LlmModel.provider_params.dig(provider&.to_sym, :fields).to_a
received_prov_params = params.dig(:ai_llm, :provider_params)
permitted[:provider_params] = received_prov_params.slice(
*extra_field_names,
).permit! if !extra_field_names.empty? && received_prov_params.present?
extra_field_names = LlmModel.provider_params.dig(provider&.to_sym)
if extra_field_names.present?
received_prov_params =
params.dig(:ai_llm, :provider_params)&.slice(*extra_field_names.keys)
if received_prov_params.present?
received_prov_params.each do |pname, value|
if extra_field_names[pname.to_sym] == :checkbox
received_prov_params[pname] = ActiveModel::Type::Boolean.new.cast(value)
end
end
permitted[:provider_params] = received_prov_params.permit!
end
end
permitted
end
@@ -9,6 +9,7 @@ module DiscourseAi
def search
query = params[:q].to_s
skip_hyde = params[:hyde].downcase.to_s == "false" || params[:hyde].to_s == "0"
if query.length < SiteSetting.min_search_term_length
raise Discourse::InvalidParameters.new(:q)
@@ -31,7 +32,7 @@ module DiscourseAi
hijack do
semantic_search
.search_for_topics(query)
.search_for_topics(query, _page = 1, hyde: !skip_hyde)
.each { |topic_post| grouped_results.add(topic_post) }
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
@@ -39,6 +40,9 @@ module DiscourseAi
end
def quick_search
# this search function searches posts (vs: topics)
# it requires post embeddings and a reranker
# it will not perform a hyde expantion
query = params[:q].to_s
if query.length < SiteSetting.min_search_term_length
@@ -12,6 +12,13 @@ module ::Jobs
return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE]
DiscourseAi::AiBot::Playground.new(bot).title_playground(post)
publish_update(post.topic, { title: post.topic.title })
end
def publish_update(topic, payload)
allowed_users = topic.topic_allowed_users.pluck(:user_id)
MessageBus.publish("/discourse-ai/ai-bot/topic/#{topic.id}", payload, user_ids: allowed_users)
end
end
end
+16 -4
View File
@@ -17,12 +17,20 @@ class LlmModel < ActiveRecord::Base
def self.provider_params
{
aws_bedrock: {
url_editable: false,
fields: %i[access_key_id region],
access_key_id: :text,
region: :text,
},
open_ai: {
url_editable: true,
fields: %i[organization],
organization: :text,
},
hugging_face: {
disable_system_prompt: :checkbox,
},
vllm: {
disable_system_prompt: :checkbox,
},
ollama: {
disable_system_prompt: :checkbox,
},
}
end
@@ -81,6 +89,10 @@ class LlmModel < ActiveRecord::Base
provider_params&.dig(key)
end
def seeded?
id < 0
end
private
def required_provider_params
+12
View File
@@ -21,4 +21,16 @@ class LlmModelSerializer < ApplicationSerializer
def used_by
DiscourseAi::Configuration::LlmValidator.new.modules_using(object)
end
def api_key
object.seeded? ? "********" : object.api_key
end
def url
object.seeded? ? "********" : object.url
end
def provider
object.seeded? ? "CDCK" : object.provider
end
end
@@ -20,26 +20,25 @@ export default class AiTool extends RestModel {
return this.getProperties(CREATE_ATTRIBUTES);
}
workingCopy() {
const attrs = this.getProperties(CREATE_ATTRIBUTES);
attrs.parameters = new TrackedArray(
attrs.parameters?.map((p) => {
trackParameters(parameters) {
return new TrackedArray(
parameters?.map((p) => {
const parameter = new TrackedObject(p);
//Backwards-compatibility code.
// TODO(roman): Remove aug 2024. Leave only else clause.
if (parameter.enum_values) {
parameter.enum = new TrackedArray(parameter.enum_values);
delete parameter.enum_values;
} else {
if (parameter.enum && parameter.enum.length) {
parameter.enum = new TrackedArray(parameter.enum);
} else {
parameter.enum = null;
}
return parameter;
})
);
}
workingCopy() {
const attrs = this.getProperties(CREATE_ATTRIBUTES);
attrs.parameters = this.trackParameters(attrs.parameters);
return this.store.createRecord("ai-tool", attrs);
}
}
@@ -1,6 +1,5 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
@@ -17,6 +16,7 @@ import ModalDiffModal from "../components/modal/diff-modal";
import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions";
export default class AiComposerHelperMenu extends Component {
@service modal;
@service siteSettings;
@service aiComposerHelper;
@service currentUser;
@@ -29,7 +29,6 @@ export default class AiComposerHelperMenu extends Component {
@tracked lastUsedOption = null;
@tracked thumbnailSuggestions = null;
@tracked showThumbnailModal = false;
@tracked showDiffModal = false;
@tracked lastSelectionRange = null;
MENU_STATES = this.aiComposerHelper.MENU_STATES;
prompts = [];
@@ -100,7 +99,16 @@ export default class AiComposerHelperMenu extends Component {
{
icon: "exchange-alt",
label: "discourse_ai.ai_helper.context_menu.view_changes",
action: () => (this.showDiffModal = true),
action: () =>
this.modal.show(ModalDiffModal, {
model: {
diff: this.diff,
oldValue: this.initialValue,
newValue: this.newSelectedText,
revert: this.undoAiAction,
confirm: () => this.updateMenuState(this.MENU_STATES.resets),
},
}),
classes: "view-changes",
},
{
@@ -154,6 +162,14 @@ export default class AiComposerHelperMenu extends Component {
return true;
}
get isExpanded() {
if (this.aiComposerHelper.menuState === this.MENU_STATES.triggers) {
return "";
}
return "is-expanded";
}
@bind
onKeyDown(event) {
if (event.key === "Escape") {
@@ -202,8 +218,12 @@ export default class AiComposerHelperMenu extends Component {
if (option.name === "illustrate_post") {
this._toggleLoadingState(false);
this.closeMenu();
this.showThumbnailModal = true;
this.thumbnailSuggestions = data.thumbnails;
this.modal.show(ThumbnailSuggestion, {
model: {
thumbnails: this.thumbnailSuggestions,
},
});
} else {
this._updateSuggestedByAi(data);
}
@@ -294,7 +314,10 @@ export default class AiComposerHelperMenu extends Component {
}
<template>
<div class="ai-composer-helper-menu" {{this.documentListeners}}>
<div
class="ai-composer-helper-menu {{this.isExpanded}}"
{{this.documentListeners}}
>
{{#if (eq this.aiComposerHelper.menuState this.MENU_STATES.triggers)}}
<ul class="ai-composer-helper-menu__triggers">
<li>
@@ -326,26 +349,5 @@ export default class AiComposerHelperMenu extends Component {
/>
{{/if}}
</div>
{{#if this.showDiffModal}}
<ModalDiffModal
@confirm={{fn
(mut this.aiComposerHelper.menuState)
this.MENU_STATES.resets
}}
@diff={{this.diff}}
@oldValue={{this.initialValue}}
@newValue={{this.newSelectedText}}
@revert={{this.undoAiAction}}
@closeModal={{fn (mut this.showDiffModal) false}}
/>
{{/if}}
{{#if this.showThumbnailModal}}
<ThumbnailSuggestion
@thumbnails={{this.thumbnailSuggestions}}
@closeModal={{fn (mut this.showThumbnailModal) false}}
/>
{{/if}}
</template>
}
@@ -7,6 +7,7 @@ import { action, computed } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import Avatar from "discourse/helpers/bound-avatar-template";
@@ -52,15 +53,19 @@ export default class AiLlmEditorForm extends Component {
return this.testRunning || this.testResult !== null;
}
@computed("args.model.provider")
get canEditURL() {
// Explicitly false.
return this.metaProviderParams.url_editable !== false;
return this.args.model.provider !== "aws_bedrock";
}
get modulesUsingModel() {
return this.args.model.used_by?.join(", ");
}
get seeded() {
return this.args.model.id < 0;
}
get inUseWarning() {
return I18n.t("discourse_ai.llms.in_use_warning", {
settings: this.modulesUsingModel,
@@ -169,13 +174,19 @@ export default class AiLlmEditorForm extends Component {
}
<template>
{{#if this.seeded}}
<div class="alert alert-info">
{{icon "exclamation-circle"}}
{{i18n "discourse_ai.llms.seeded_warning"}}
</div>
{{/if}}
{{#if this.modulesUsingModel}}
<div class="alert alert-info">
{{icon "exclamation-circle"}}
{{this.inUseWarning}}
</div>
{{/if}}
<form class="form-horizontal ai-llm-editor">
<form class="form-horizontal ai-llm-editor {{if this.seeded 'seeded'}}">
<div class="control-group">
<label>{{i18n "discourse_ai.llms.display_name"}}</label>
<Input
@@ -201,123 +212,136 @@ export default class AiLlmEditorForm extends Component {
<ComboBox
@value={{@model.provider}}
@content={{this.selectedProviders}}
@class="ai-llm-editor__provider"
/>
</div>
{{#if this.canEditURL}}
{{#unless this.seeded}}
{{#if this.canEditURL}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.url"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__url"
@type="text"
@value={{@model.url}}
required="true"
/>
</div>
{{/if}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.url"}}</label>
<label>{{I18n.t "discourse_ai.llms.api_key"}}</label>
<div class="ai-llm-editor__secret-api-key-group">
<Input
@value={{@model.api_key}}
class="ai-llm-editor-input ai-llm-editor__api-key"
@type={{if this.apiKeySecret "password" "text"}}
required="true"
{{on "focusout" this.makeApiKeySecret}}
/>
<DButton
@action={{this.toggleApiKeySecret}}
@icon="far-eye-slash"
/>
</div>
</div>
{{#each-in this.metaProviderParams as |field type|}}
<div class="control-group ai-llm-editor-provider-param__{{type}}">
<label>{{I18n.t
(concat "discourse_ai.llms.provider_fields." field)
}}</label>
{{#if (eq type "checkbox")}}
<Input
@type={{type}}
@checked={{mut (get @model.provider_params field)}}
/>
{{else}}
<Input
@type={{type}}
@value={{mut (get @model.provider_params field)}}
/>
{{/if}}
</div>
{{/each-in}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.tokenizer"}}</label>
<ComboBox
@value={{@model.tokenizer}}
@content={{@llms.resultSetMeta.tokenizers}}
@class="ai-llm-editor__tokenizer"
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.max_prompt_tokens"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__url"
@type="text"
@value={{@model.url}}
@type="number"
class="ai-llm-editor-input ai-llm-editor__max-prompt-tokens"
step="any"
min="0"
lang="en"
@value={{@model.max_prompt_tokens}}
required="true"
/>
</div>
{{/if}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.api_key"}}</label>
<div class="ai-llm-editor__secret-api-key-group">
<Input
@value={{@model.api_key}}
class="ai-llm-editor-input ai-llm-editor__api-key"
@type={{if this.apiKeySecret "password" "text"}}
required="true"
{{on "focusout" this.makeApiKeySecret}}
/>
<DButton @action={{this.toggleApiKeySecret}} @icon="far-eye-slash" />
</div>
</div>
{{#each this.metaProviderParams.fields as |field|}}
<div class="control-group">
<label>{{I18n.t
(concat "discourse_ai.llms.provider_fields." field)
}}</label>
<Input
@type="text"
@value={{mut (get @model.provider_params field)}}
class="ai-llm-editor-input ai-llm-editor__{{field}}"
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.llms.hints.max_prompt_tokens"}}
/>
</div>
{{/each}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.tokenizer"}}</label>
<ComboBox
@value={{@model.tokenizer}}
@content={{@llms.resultSetMeta.tokenizers}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.max_prompt_tokens"}}</label>
<Input
@type="number"
class="ai-llm-editor-input ai-llm-editor__max-prompt-tokens"
step="any"
min="0"
lang="en"
@value={{@model.max_prompt_tokens}}
required="true"
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.llms.hints.max_prompt_tokens"}}
/>
</div>
<div class="control-group ai-llm-editor__vision-enabled">
<Input @type="checkbox" @checked={{@model.vision_enabled}} />
<label>{{I18n.t "discourse_ai.llms.vision_enabled"}}</label>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.llms.hints.vision_enabled"}}
/>
</div>
<div class="control-group">
<DToggleSwitch
class="ai-llm-editor__enabled-chat-bot"
@state={{@model.enabled_chat_bot}}
@label="discourse_ai.llms.enabled_chat_bot"
{{on "click" this.toggleEnabledChatBot}}
/>
</div>
{{#if @model.user}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.ai_bot_user"}}</label>
<a
class="avatar"
href={{@model.user.path}}
data-user-card={{@model.user.username}}
>
{{Avatar @model.user.avatar_template "small"}}
</a>
<LinkTo @route="adminUser" @model={{this.adminUser}}>
{{@model.user.username}}
</LinkTo>
<div class="control-group ai-llm-editor__vision-enabled">
<Input @type="checkbox" @checked={{@model.vision_enabled}} />
<label>{{I18n.t "discourse_ai.llms.vision_enabled"}}</label>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.llms.hints.vision_enabled"}}
/>
</div>
{{/if}}
<div class="control-group ai-llm-editor__action_panel">
<DButton
class="ai-llm-editor__test"
@action={{this.test}}
@disabled={{this.testRunning}}
>
{{I18n.t "discourse_ai.llms.tests.title"}}
</DButton>
<DButton
class="btn-primary ai-llm-editor__save"
@action={{this.save}}
@disabled={{this.isSaving}}
>
{{I18n.t "discourse_ai.llms.save"}}
</DButton>
{{#unless @model.isNew}}
<div class="control-group">
<DToggleSwitch
class="ai-llm-editor__enabled-chat-bot"
@state={{@model.enabled_chat_bot}}
@label="discourse_ai.llms.enabled_chat_bot"
{{on "click" this.toggleEnabledChatBot}}
/>
</div>
{{#if @model.user}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.ai_bot_user"}}</label>
<a
class="avatar"
href={{@model.user.path}}
data-user-card={{@model.user.username}}
>
{{Avatar @model.user.avatar_template "small"}}
</a>
<LinkTo @route="adminUser" @model={{this.adminUser}}>
{{@model.user.username}}
</LinkTo>
</div>
{{/if}}
<div class="control-group ai-llm-editor__action_panel">
<DButton
@action={{this.delete}}
class="btn-danger ai-llm-editor__delete"
class="ai-llm-editor__test"
@action={{this.test}}
@disabled={{this.testRunning}}
>
{{I18n.t "discourse_ai.llms.delete"}}
{{I18n.t "discourse_ai.llms.tests.title"}}
</DButton>
{{/unless}}
</div>
<DButton
class="btn-primary ai-llm-editor__save"
@action={{this.save}}
@disabled={{this.isSaving}}
>
{{I18n.t "discourse_ai.llms.save"}}
</DButton>
{{#unless @model.isNew}}
<DButton
@action={{this.delete}}
class="btn-danger ai-llm-editor__delete"
>
{{I18n.t "discourse_ai.llms.delete"}}
</DButton>
{{/unless}}
</div>
{{/unless}}
<div class="control-group ai-llm-editor-tests">
{{#if this.displayTestResult}}
@@ -25,6 +25,7 @@ export default class AiToolEditor extends Component {
@service dialog;
@service modal;
@service toasts;
@service store;
@tracked isSaving = false;
@tracked editingModel = null;
@@ -53,8 +54,9 @@ export default class AiToolEditor extends Component {
@action
configurePreset() {
this.selectedPreset = this.args.presets.findBy("preset_id", this.presetId);
this.editingModel = this.args.model.workingCopy();
this.editingModel.setProperties(this.selectedPreset);
this.editingModel = this.store
.createRecord("ai-tool", this.selectedPreset)
.workingCopy();
this.showDelete = false;
}
@@ -192,7 +194,7 @@ export default class AiToolEditor extends Component {
<label>{{I18n.t "discourse_ai.tools.script"}}</label>
<AceEditor
@content={{this.editingModel.script}}
@onChange={{withEventValue (fn (mut this.editingModel.script))}}
@onChange={{fn (mut this.editingModel.script)}}
@mode={{ACE_EDITOR_MODE}}
@theme={{ACE_EDITOR_THEME}}
@editorId="ai-tool-script-editor"
@@ -7,7 +7,6 @@ import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import I18n from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
import and from "truth-helpers/helpers/and";
const PARAMETER_TYPES = [
{ name: "string", id: "string" },
@@ -59,6 +58,9 @@ export default class AiToolParameterEditor extends Component {
@action
removeEnumValue(parameter, index) {
parameter.enum.splice(index, 1);
if (parameter.enum.length === 0) {
parameter.enum = null;
}
}
@action
@@ -94,6 +96,7 @@ export default class AiToolParameterEditor extends Component {
{{on "input" (fn this.toggleRequired parameter)}}
checked={{parameter.required}}
type="checkbox"
class="parameter-row__required-toggle"
/>
{{I18n.t "discourse_ai.tools.parameter_required"}}
</label>
@@ -101,8 +104,9 @@ export default class AiToolParameterEditor extends Component {
<label>
<input
{{on "input" (fn this.toggleEnum parameter)}}
checked={{and parameter.enum parameter.enum.length}}
checked={{parameter.enum}}
type="checkbox"
class="parameter-row__enum-toggle"
/>
{{I18n.t "discourse_ai.tools.parameter_enum"}}
</label>
@@ -114,7 +118,7 @@ export default class AiToolParameterEditor extends Component {
/>
</div>
{{#if (and parameter.enum parameter.enum.length)}}
{{#if parameter.enum}}
<div class="parameter-enum-values">
{{#each parameter.enum as |enumValue enumIndex|}}
<div class="enum-value-row">
@@ -1,21 +1,86 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
export default class ModalDiffModal extends Component {
@service currentUser;
@tracked loading = false;
@tracked diff;
suggestion = "";
PROOFREAD_ID = -303;
constructor() {
super(...arguments);
this.diff = this.args.model.diff;
next(() => {
if (this.args.model.toolbarEvent) {
this.loadDiff();
}
});
}
async loadDiff() {
this.loading = true;
try {
const suggestion = await ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: this.PROOFREAD_ID,
text: this.selectedText,
force_default_locale: true,
},
});
this.diff = suggestion.diff;
this.suggestion = suggestion.suggestions[0];
} catch (e) {
popupAjaxError(e);
} finally {
this.loading = false;
}
}
get selectedText() {
const selected = this.args.model.toolbarEvent.selected;
if (selected.value === "") {
return selected.pre + selected.post;
}
return selected.value;
}
@action
triggerConfirmChanges() {
this.args.closeModal();
this.args.confirm();
if (this.args.model.confirm) {
this.args.model.confirm();
}
if (this.args.model.toolbarEvent && this.suggestion) {
this.args.model.toolbarEvent.replaceText(
this.selectedText,
this.suggestion
);
}
}
@action
triggerRevertChanges() {
this.args.model.revert();
this.args.closeModal();
this.args.revert();
}
<template>
@@ -25,30 +90,46 @@ export default class ModalDiffModal extends Component {
@closeModal={{@closeModal}}
>
<:body>
{{#if @diff}}
{{htmlSafe @diff}}
{{#if this.loading}}
<div class="composer-ai-helper-modal__loading">
<CookText @rawText={{this.selectedText}} />
</div>
{{else}}
<div class="composer-ai-helper-modal__old-value">
{{@oldValue}}
</div>
{{#if this.diff}}
{{htmlSafe this.diff}}
{{else}}
<div class="composer-ai-helper-modal__old-value">
{{@model.oldValue}}
</div>
<div class="composer-ai-helper-modal__new-value">
{{@newValue}}
</div>
<div class="composer-ai-helper-modal__new-value">
{{@model.newValue}}
</div>
{{/if}}
{{/if}}
</:body>
<:footer>
<DButton
class="btn-primary confirm"
@action={{this.triggerConfirmChanges}}
@label="discourse_ai.ai_helper.context_menu.confirm"
/>
<DButton
class="btn-flat"
@action={{this.triggerRevertChanges}}
@label="discourse_ai.ai_helper.context_menu.revert"
/>
{{#if this.loading}}
<DButton
class="btn-primary"
@label="discourse_ai.ai_helper.context_menu.loading"
@disabled={{true}}
/>
{{else}}
<DButton
class="btn-primary confirm"
@action={{this.triggerConfirmChanges}}
@label="discourse_ai.ai_helper.context_menu.confirm"
/>
{{#if @model.revert}}
<DButton
class="btn-flat revert"
@action={{this.triggerRevertChanges}}
@label="discourse_ai.ai_helper.context_menu.revert"
/>
{{/if}}
{{/if}}
</:footer>
</DModal>
</template>
@@ -53,7 +53,7 @@ export default class ThumbnailSuggestions extends Component {
>
<:body>
<div class="ai-thumbnail-suggestions">
{{#each @thumbnails as |thumbnail|}}
{{#each @model.thumbnails as |thumbnail|}}
<ThumbnailSuggestionItem
@thumbnail={{thumbnail}}
@addSelection={{this.addSelection}}
@@ -14,6 +14,7 @@ export default class AiComposerHelper extends Component {
return showComposerAIHelper(outletArgs, helper, "context_menu");
}
@service site;
@service menu;
@service aiComposerHelper;
@tracked caretCoords;
@@ -31,6 +32,9 @@ export default class AiComposerHelper extends Component {
document.addEventListener("mousedown", this.onMouseDown, { passive: true });
document.addEventListener("mouseup", this.onMouseUp, { passive: true });
document.addEventListener("selectionchange", this.onSelectionChanged);
window.visualViewport.addEventListener("resize", this.onResize, {
passive: true,
});
this.dEditorInput = document.querySelector(".d-editor-input");
@@ -42,6 +46,7 @@ export default class AiComposerHelper extends Component {
document.removeEventListener("mousedown", this.onMouseDown);
document.removeEventListener("mouseup", this.onMouseUp);
document.removeEventListener("selectionchange", this.onSelectionChanged);
window.visualViewport.removeEventListener("resize", this.onResize);
if (this.dEditorInput) {
this.dEditorInput.removeEventListener("scroll", this.updatePosition);
@@ -54,6 +59,18 @@ export default class AiComposerHelper extends Component {
this.menuInstance?.close();
}
@bind
onResize() {
if (!this.site.mobileView) {
return;
}
document.documentElement.style.setProperty(
"--mobile-virtual-screen-height",
`${window.visualViewport.height}px`
);
}
@bind
onSelectionChanged() {
if (
@@ -69,8 +69,10 @@ export default class AiSummaryBox extends Component {
}
@bind
subscribe(unsubscribe) {
if (unsubscribe && this._channel) {
subscribe(unsubscribe, [topicId]) {
const sameTopicId = this.args.outletArgs.topic.id === topicId;
if (unsubscribe && this._channel && !sameTopicId) {
this.unsubscribe();
}
const channel = `/discourse-ai/summaries/topic/${this.args.outletArgs.topic.id}`;
@@ -175,7 +177,7 @@ export default class AiSummaryBox extends Component {
>
<DMenu
@onShow={{this.generateSummary}}
@arrow={{true}}
@arrow={{false}}
@identifier="topic-map__ai-summary"
@onRegisterApi={{this.onRegisterApi}}
@interactive={{true}}
@@ -3,19 +3,26 @@ import { LinkTo } from "@ember/routing";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
function showAiPreferences(user) {
function showAiPreferences(user, settings) {
// Since we only have one AI related user setting we don't show
// AI preferences if these conditions aren't met.
// If we add more user settings in the future we can move this
// logic to the the specific settings and conditionally show it in the template.
return user?.user_allowed_ai_auto_image_captions;
const aiHelperEnabledFeatures =
settings.ai_helper_enabled_features.split("|");
return (
user?.user_allowed_ai_auto_image_captions &&
aiHelperEnabledFeatures.includes("image_caption") &&
settings.ai_helper_enabled
);
}
export default class AutoImageCaptionSetting extends Component {
static shouldRender(outletArgs, helper) {
return (
helper.siteSettings.discourse_ai_enabled &&
showAiPreferences(outletArgs.model)
showAiPreferences(outletArgs.model, helper.siteSettings)
);
}
@@ -11,6 +11,17 @@ export default class PreferencesAiController extends Controller {
@service siteSettings;
@tracked saved = false;
get showAutoImageCaptionSetting() {
const aiHelperEnabledFeatures =
this.siteSettings.ai_helper_enabled_features.split("|");
return (
this.model?.user_allowed_ai_auto_image_captions &&
aiHelperEnabledFeatures.includes("image_caption") &&
this.siteSettings.ai_helper_enabled
);
}
@action
save() {
this.saved = false;
@@ -1,17 +1,24 @@
<label class="control-label">{{i18n "discourse_ai.title"}}</label>
{{!
Later when we have more preferences,
move the conditional (showAutoImageCaptionSetting)
to be only around the auto-image-caption preference.
}}
{{#if this.showAutoImageCaptionSetting}}
<label class="control-label">{{i18n "discourse_ai.title"}}</label>
<div class="control-group ai-setting">
<PreferenceCheckbox
@labelKey="discourse_ai.ai_helper.image_caption.automatic_caption_setting"
@checked={{this.model.user_option.auto_image_caption}}
data-setting-name="auto-image-caption"
class="pref-auto-image-caption"
<div class="control-group ai-setting">
<PreferenceCheckbox
@labelKey="discourse_ai.ai_helper.image_caption.automatic_caption_setting"
@checked={{this.model.user_option.auto_image_caption}}
data-setting-name="auto-image-caption"
class="pref-auto-image-caption"
/>
</div>
<SaveControls
@id="user_ai_preference_save"
@model={{this.model}}
@action={{this.save}}
@saved={{this.saved}}
/>
</div>
<SaveControls
@id="user_ai_preference_save"
@model={{this.model}}
@action={{this.save}}
@saved={{this.saved}}
/>
{{/if}}
@@ -0,0 +1,50 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import i18n from "discourse-common/helpers/i18n";
import ModalDiffModal from "../discourse/components/modal/diff-modal";
function initializeProofread(api) {
api.addComposerToolbarPopupMenuOption({
action: (toolbarEvent) => {
const modal = api.container.lookup("service:modal");
const composer = api.container.lookup("service:composer");
const toasts = api.container.lookup("service:toasts");
if (composer.model.reply?.length === 0) {
toasts.error({
duration: 3000,
data: {
message: i18n("discourse_ai.ai_helper.proofread.no_content_error"),
},
});
return;
}
modal.show(ModalDiffModal, {
model: {
toolbarEvent,
},
});
},
icon: "spell-check",
label: "discourse_ai.ai_helper.context_menu.proofread_prompt",
shortcut: "ALT+P",
condition: () => {
const siteSettings = api.container.lookup("service:site-settings");
const currentUser = api.getCurrentUser();
return (
siteSettings.ai_helper_enabled && currentUser?.can_use_assistant_in_post
);
},
});
}
export default {
name: "discourse-ai-helper",
initialize() {
withPluginApi("1.1.0", (api) => {
initializeProofread(api);
});
},
};
@@ -84,10 +84,10 @@ export default apiInitializer("1.25.0", (api) => {
}
);
// Checks if image is small (≤ 0.4 MP)
// Checks if image is small (≤ 0.1 MP)
function isSmallImage(width, height) {
const megapixels = (width * height) / 1000000;
return megapixels <= 0.4;
return megapixels <= 0.1;
}
function needsImprovedCaption(caption) {
@@ -145,6 +145,9 @@ export default apiInitializer("1.25.0", (api) => {
}
const caption = await fetchImageCaption(upload.url, "long_url");
if (!caption) {
return getUploadMarkdown(upload);
}
return `![${caption}|${upload.thumbnail_width}x${upload.thumbnail_height}](${upload.short_url})`;
});
@@ -14,6 +14,18 @@
height: 200px;
}
}
@keyframes fadeOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
&__loading {
animation: fadeOpacity 1.5s infinite alternate;
}
&__old-value {
background-color: var(--danger-low);
@@ -0,0 +1,87 @@
:root {
// Overridden in ai-composer-helper.gjs to adjust for virtual keyboard
--mobile-virtual-screen-height: 100svh;
}
.ai-composer-helper-menu:not(.is-expanded) {
--composer-helper-menu-padding: 1rem;
--composer-helper-menu-trigger-size: 3em;
--composer-helper-menu-height: calc(1rem + 3em);
position: fixed;
list-style: none;
inset: auto;
transform: none;
max-width: unset;
z-index: z("fullscreen");
top: calc(
var(--mobile-virtual-screen-height) - var(--composer-helper-menu-height) -
env(keyboard-inset-height)
);
right: 0;
padding-right: 1rem;
padding-bottom: var(--composer-helper-menu-padding);
margin: 0px;
width: 100vw;
text-align: right;
border: none;
background: linear-gradient(
to bottom,
transparent 0,
rgba(0, 0, 0, 0.3) 100%
);
box-shadow: none;
&.out-of-bounds {
visibility: visible;
pointer-events: initial;
}
button {
background: var(--secondary);
border: 1px solid var(--primary-low);
border-radius: 100%;
box-shadow: 0 0 10px 2px
dark-light-choose(
rgba(var(--primary-rgb), 0.1),
rgba(var(--secondary-rgb), 0.1)
);
width: var(--composer-helper-menu-trigger-size);
height: var(--composer-helper-menu-trigger-size);
animation-duration: 0.15s;
animation-timing-function: cubic-bezier(0, 0.7, 0.9, 1.2);
animation-fill-mode: backwards;
animation-name: slide-in;
&:hover {
.discourse-no-touch & {
background: var(--secondary);
}
}
.d-button-label {
display: none;
}
.d-icon {
margin: 0;
color: var(--tertiary-high);
}
}
}
.ios-safari-composer-hacks .ai-composer-helper-menu:not(.is-expanded) {
top: calc(
var(--mobile-virtual-screen-height) - var(--composer-helper-menu-height)
);
}
@keyframes slide-in {
0% {
opacity: 0;
transform: translateY(calc(100% + 1rem));
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@@ -18,6 +18,15 @@
width: 350px;
}
.ai-llm-editor-provider-param {
&__checkbox {
display: flex;
align-items: flex-start;
flex-direction: row-reverse;
justify-content: left;
}
}
.fk-d-tooltip__icon {
padding-left: 0.25em;
color: var(--primary-medium);
File diff suppressed because it is too large Load Diff
@@ -9,14 +9,9 @@
max-width: 470px !important; //overruling JS
max-height: calc(100vh - var(--header-offset) - 3rem);
&__inner-content {
max-height: unset;
padding: 0;
}
.ai-summary__header,
.ai-summary-box {
padding-inline: 1.5rem;
padding: 0.75em 1rem;
box-sizing: border-box;
}
@@ -36,9 +31,6 @@
margin: 0;
}
}
&-box {
padding-bottom: 1rem;
}
}
}
}
@@ -0,0 +1,3 @@
.ai-summary-box {
padding: 0.75em 1rem;
}
+7 -2
View File
@@ -236,11 +236,11 @@ en:
back: "Back"
confirm_delete: Are you sure you want to delete this model?
delete: Delete
seeded_warning: "This model is pre-configured on your site and cannot be edited."
in_use_warning:
one: "This model is currently used by the %{settings} setting. If misconfigured, the feature won't work as expected."
other: "This model is currently used by the following settings: %{settings}. If misconfigured, features won't work as expected. "
preconfigured_llms: "Select your LLM"
preconfigured:
none: "Configure manually..."
@@ -268,11 +268,13 @@ en:
google: "Google"
azure: "Azure"
ollama: "Ollama"
CDCK: "CDCK"
provider_fields:
access_key_id: "AWS Bedrock Access key ID"
region: "AWS Bedrock Region"
organization: "Optional OpenAI Organization ID"
disable_system_prompt: "Disable system message in prompts"
related_topics:
title: "Related Topics"
@@ -293,12 +295,13 @@ en:
view_changes: "View Changes"
confirm: "Confirm"
revert: "Revert"
changes: "Changes"
changes: "Suggested Edits"
custom_prompt:
title: "Custom Prompt"
placeholder: "Enter a custom prompt..."
submit: "Send Prompt"
translate_prompt: "Translate to %{language}"
proofread_prompt: "Proofread Text"
post_options_menu:
trigger: "Ask AI"
title: "Ask AI"
@@ -327,6 +330,8 @@ en:
prompt: "This post contains non-captioned images. Would you like to enable automatic AI captions on image uploads? (This can be changed in your preferences later)"
confirm: "Enable"
cancel: "Don't ask again"
proofread:
no_content_error: "Unable to proofread text, please add some content to proofread."
reviewables:
model_used: "Model used:"
+4
View File
@@ -11,6 +11,10 @@ de:
title: Beiträge mithilfe von KI sortieren
description: "Beiträge mithilfe eines großen Sprachmodells sortieren"
system_prompt_missing_post_placeholder: "System-Eingabeaufforderung muss einen Platzhalter für den Beitrag enthalten: %%POST%%"
flagged_post: |
<div>Antwort des Modells:</div>
<p>%%LLM_RESPONSE%%</p>
<b>Ausgelöst durch die <a href="/admin/plugins/discourse-automation/%%AUTOMATION_ID%%">%%AUTOMATION_NAME%%</a> Regel.</b>
llm_report:
title: Regelmäßiger Bericht mit KI
description: "Regelmäßiger Bericht auf der Grundlage eines großen Sprachmodells"
+7 -1
View File
@@ -4,7 +4,10 @@ en:
llm_triage:
title: Triage posts using AI
description: "Triage posts using a large language model"
system_prompt_missing_post_placeholder: "System prompt must contain a placeholder for the post: %%POST%%"
flagged_post: |
<div>Response from the model:</div>
<p>%%LLM_RESPONSE%%</p>
<b>Triggered by the <a href="/admin/plugins/discourse-automation/%%AUTOMATION_ID%%">%%AUTOMATION_NAME%%</a> rule.</b>
llm_report:
title: Periodic report using AI
description: "Periodic report based on a large language model"
@@ -76,6 +79,7 @@ en:
ai_summarization_enabled: "Enable the topic summarization module."
ai_summarization_model: "Model to use for summarization."
ai_custom_summarization_allowed_groups: "Groups allowed to use create new summaries."
ai_pm_summarization_allowed_groups: "Groups allowed to create and view summaries in PMs."
ai_bot_enabled: "Enable the AI Bot module."
ai_bot_enable_chat_warning: "Display a warning when PM chat is initiated. Can be overriden by editing the translation string: discourse_ai.ai_bot.pm_warning"
@@ -308,6 +312,7 @@ en:
disable_module_first: "You have to disable %{setting} first."
set_llm_first: "Set %{setting} first."
model_unreachable: "We couldn't get a response from this model. Check your settings first."
invalid_seeded_model: "You can't use this model with this feature."
endpoints:
not_configured: "%{display_name} (not configured)"
configuration_hint:
@@ -317,6 +322,7 @@ en:
delete_failed:
one: "We couldn't delete this model because %{settings} is using it. Update the setting and try again."
other: "We couldn't delete this model because %{settings} are using it. Update the settings and try again."
cannot_edit_builtin: "You can't edit a built-in model."
embeddings:
configuration:
+4
View File
@@ -11,6 +11,10 @@ he:
title: מיון ראשוני של פוסטים באמצעות בינה מלאכותית
description: "מיון ראשוני של פוסטים באמצעות דגם שפה גדול"
system_prompt_missing_post_placeholder: "בקשת המערכת חייבת להכיל ממלא מקום לפוסט: %%POST%%"
flagged_post: |
<div>תגובה מהמודל:</div>
<p>%%LLM_RESPONSE%%</p>
<b>הוזנקה על ידי הכלל <a href="/admin/plugins/discourse-automation/%%AUTOMATION_ID%%">%%AUTOMATION_NAME%%</a>.</b>
llm_report:
title: דוח תקופתי בעזרת בינה מלאכותית
description: "דוח תקופתי שמבוסס על דגם שפה גדול"

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