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
Discourse Translator Bot 72980a3d84 Update translations (#751) 2024-08-13 16:33:25 +02:00
Sam 14443bf890 FIX: more robust summary implementation (#750)
When navigating between topic we were not correctly resetting
internal state for summarization. This leads to a situation where
incorrect summaries can be displayed to users and wrong summaries
can be displayed.

Additionally our controller for grabbing summaries was always
streaming results via message bus, which could be delayed when
sidekiq is overloaded. We now will return the cached summary
right away if it is available direct from REST endpoint.
2024-08-13 08:47:47 -03:00
Keegan George f72ab12761 DEV: Clearly separate post/composer helper settings (#747) 2024-08-12 15:40:23 -07:00
Keegan George a1eb1ba180 FIX: AI helper not being shown on mobile view (#749) 2024-08-12 15:32:49 -07:00
Sam cf1e15ef12 FIX: gemini 0801 tool calls (#748)
Gemini experimental model requires tool_config.

Previously defaults would apply.

This corrects prompts containing multiple tools on gemini.
2024-08-12 16:10:16 +10:00
Discourse Translator Bot a7c0ddba97 Update translations (#741) 2024-08-08 18:12:35 -03:00
Keegan George 0be292f247 FIX: auto_image_caption not always present for current user. (#746) 2024-08-08 13:37:26 -07:00
Keegan George 1d6a6c9f8f FEATURE: Stream other post helper options (#745) 2024-08-08 11:32:39 -07:00
Rafael dos Santos Silva 1686a8a683 DEV: Move to single table per embeddings type (#561)
Also move us to halfvecs for speed and disk usage gains
2024-08-08 11:55:20 -03:00
Roman Rizzi 20efc9285e FIX: Correctly save provider-specific params for new models. (#744)
Creating a new model, either manually or from presets, doesn't initialize the `provider_params` object, meaning their custom params won't persist.

Additionally, this change adds some validations for Bedrock params, which are mandatory, and a clear message when a completion fails because we cannot build the URL.
2024-08-07 16:08:56 -03:00
Rafael dos Santos Silva 3b5245dc54 FIX: Handle nil reply_to_post in AI Bot event handler (#743)
Somehow it's possible to have a nil `post.reply_to_post` with
a non-nil `post.reply_to_post_number`.
2024-08-07 14:22:32 -03:00
Keegan George 1254d7c7d0 REFACTOR: AI Composer Helper Menu (#715) 2024-08-06 10:57:39 -07:00
Roman Rizzi 7b4c099673 FIX: LlmModel validations. (#742)
- Validate fields to reduce the chance of breaking features by a misconfigured model.
- Fixed a bug where the URL might get deleted during an update.
- Display a warning when a model is currently in use.
2024-08-06 14:35:35 -03:00
chapoiandRafael Silva d17bbc2dc4 UX: summary fixed positioning (#740)
Co-authored-by: Rafael Silva <[email protected]>
2024-08-05 12:39:08 -03:00
dependabot[bot] 5c9f610fa4 Build(deps-dev): Bump rexml from 3.3.1 to 3.3.3 (#738)
Bumps [rexml](https://github.com/ruby/rexml) from 3.3.1 to 3.3.3.
- [Release notes](https://github.com/ruby/rexml/releases)
- [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/rexml/compare/v3.3.1...v3.3.3)

---
updated-dependencies:
- dependency-name: rexml
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 13:10:20 +02:00
Keegan George 2ff3fe3a9f UX: Use stacked line chart for post sentiment (#737) 2024-08-02 14:23:29 -07:00
Roman Rizzi acc3f817e1 UX: Clarify purpose of OpenAI API key setting (#739) 2024-08-02 15:57:40 -03:00
Sam 948cf893a9 FIX: Add tool support to open ai compatible dialect and vllm (#734)
* FIX: Add tool support to open ai compatible dialect and vllm

Automatic tools are in progress in vllm see: https://github.com/vllm-project/vllm/pull/5649

Even when they are supported, initial support will be uneven, only some models have native tool support
notably mistral which has some special tokens for tool support.

After the above PR lands in vllm we will still need to swap to
XML based tools on models without native tool support.

* fix specs
2024-08-02 09:52:33 -03:00
Natalie Tay b7ac229547 DEV: Pin plugin for Discourse < 3.4.0.beta1-dev (#735) 2024-08-02 17:03:27 +08:00
Rafael dos Santos Silva 7dc0a2a20a DEV: Log error responses on Gemini / Cloudflare embeddings requests (#736) 2024-08-01 17:25:24 -03:00
Rafael dos Santos Silva 9d887ad4ac FIX: Use correct date for cached summary (#733) 2024-07-31 15:21:26 -03:00
Rafael dos Santos Silva d4c90bc2ba FEATURE: Make emotion analysis enabled by default (#732) 2024-07-31 12:00:00 -03:00
Sam c16c622b53 FIX: properly pass errors to client (#731)
render_json_error expects a AR model not a serializer, using a
serializer eats up the error message
2024-07-31 17:53:18 +10:00
Roman Rizzi bed044448c DEV: Remove old code now that features rely on LlmModels. (#729)
* DEV: Remove old code now that features rely on LlmModels.

* Hide old settings and migrate persona llm overrides

* Remove shadowing special URL + seeding code. Use srv:// prefix instead.
2024-07-30 13:44:57 -03:00
Discourse Translator Bot 73a2b15e91 Update translations (#730) 2024-07-30 11:08:03 -03:00
Natalie Tay 7cd7f71857 DEV: Promote historical post-deploy migrations (#728) 2024-07-30 01:44:57 +08:00
Rafael dos Santos Silva 665637fbad FIX: Properly fix ai_summaries table sequence (#727)
* FIX: Properly fix ai_summaries table sequence

Previous attempt at 3815360 could fail due to a race introduced in 1b0ba91 where summaries are migrated to core in a post_migrate erroneously.
2024-07-26 14:45:01 -03:00
Jan Cernik d58913b227 FIX: Collapse Summarize button on mobile (#726) 2024-07-25 16:10:53 -03:00
Jan Cernik d9dad56c6a UX: Use DMenu for topic summarization (#724) 2024-07-25 10:47:18 -03:00
Roman Rizzi 5c196bca89 FEATURE: Track if a model can do vision in the llm_models table (#725)
* FEATURE: Track if a model can do vision in the llm_models table

* Data migration
2024-07-24 16:29:47 -03:00
Discourse Translator Bot 06e239321b Update translations (#723) 2024-07-23 15:23:51 +02:00
Jan Cernik 738cd99ad6 FIX: Add bottom spacing for the new topic map (#722) 2024-07-22 22:21:23 -03:00
Rafael dos Santos Silva 3502f0f1cd FEATURE: GPT4o Tokenizer (#721) 2024-07-22 15:26:14 -03:00
Roman Rizzi 7f2c3a1ab9 DEV: Pin plugin for v3.2 to guarantee Ruby 3.3 compat (#719) 2024-07-17 13:16:14 -03:00
Joffrey JAFFEUX e4145ad6a6 FIX: ace-editor is now in discourse bundle, not admin (#717) 2024-07-17 11:58:50 +02:00
Discourse Translator Bot 4d8090002f Update translations (#716) 2024-07-16 22:14:09 +02:00
Roman Rizzi f328b81c78 FIX: Make sure custom tool enums follow json-schema. (#718)
Enums didn't work as expected because we the dialect couldn't translate
them correctly. It doesn't understand what "enum_values" is.
2024-07-16 14:23:17 -03:00
Roman Rizzi 0a8195242b FIX: Limit system message size to 60% of available tokens. (#714)
Using RAG fragments can lead to considerably big system messages, which becomes problematic when models have a smaller context window.

Before this change, we only look at the rest of the conversation to make sure we don't surpass the limit, which could lead to two unwanted scenarios when having large system messages:

All other messages are excluded due to size.
The system message already exceeds the limit.

As a result, I'm putting a hard-limit of 60% of available tokens. We don't want to aggresively truncate because if rag fragments are included, the system message contains a lot of context to improve the model response, but we also want to make room for the recent messages in the conversation.
2024-07-12 15:09:01 -03:00
Martin Brennan 5c1ab85583 DEV: More topic title prompt tweaks (#712)
Followup 8d4a67fbe2

The prompt worked better, but it took the instructions
about never using lowercase a little too literally,
it wasn't using it for things like LLM or Discourse,
also it was almost always framing the title as questions
so now I asked it for a mix of questions and statements
because that's less ambiguous.
2024-07-11 10:14:53 +10:00
Keegan George 08355ea5d8 FEATURE: Show post helper as bottom modal on mobile (#704) 2024-07-10 11:01:05 -07:00
Martin Brennan 8d4a67fbe2 DEV: Tweak topic title generator prompt (#710)
Changes the title generator prompt to avoid clickbait-y
titles and also try to avoid AI's favourite title format,
which is "Some Thing: Other Thing"

Leaving the chat thread title generator for now, that's
not as important, the bizarre titles add to the experience there.
2024-07-10 14:31:59 +10:00
269 changed files with 7755 additions and 3631 deletions
+4 -6
View File
@@ -1,8 +1,6 @@
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
< 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1
< 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
< 3.3.0.beta1-dev: d94234d89f50733678a5c62809445198a9fda74b
3.2.999: d94234d89f50733678a5c62809445198a9fda74b
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
3.1.0.beta3: 057fbe1ce6
+1 -1
View File
@@ -32,7 +32,7 @@ GEM
rack (3.1.6)
rainbow (3.1.1)
regexp_parser (2.9.2)
rexml (3.3.1)
rexml (3.3.3)
strscan
rubocop (1.64.1)
json (~> 2.3)
@@ -3,6 +3,7 @@ import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model() {
const record = this.store.createRecord("ai-llm");
record.provider_params = {};
return record;
},
@@ -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) {
@@ -38,24 +38,32 @@ module DiscourseAi
llm_model.toggle_companion_user
render json: { ai_persona: LlmModelSerializer.new(llm_model) }, status: :created
else
render_json_error LlmModelSerializer.new(llm_model)
render_json_error llm_model
end
end
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)
else
render_json_error LlmModelSerializer.new(llm_model)
render_json_error llm_model
end
end
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?
@@ -97,6 +105,8 @@ module DiscourseAi
private
def ai_llm_params(updating: nil)
return {} if params[:ai_llm].blank?
permitted =
params.require(:ai_llm).permit(
:display_name,
@@ -106,20 +116,30 @@ module DiscourseAi
:max_prompt_tokens,
:api_key,
:enabled_chat_bot,
:vision_enabled,
)
provider = updating ? updating.provider : permitted[:provider]
permit_url =
(updating && updating.url != LlmModel::RESERVED_VLLM_SRV_URL) ||
provider != LlmModel::BEDROCK_PROVIDER_NAME
permit_url = provider != LlmModel::BEDROCK_PROVIDER_NAME
permitted[:url] = params.dig(:ai_llm, :url) if permit_url
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
@@ -63,7 +63,7 @@ module DiscourseAi
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
else
render_json_error LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
render_json_error @ai_persona
end
end
@@ -71,7 +71,7 @@ module DiscourseAi
if @ai_persona.destroy
head :no_content
else
render_json_error LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
render_json_error @ai_persona
end
end
@@ -81,7 +81,7 @@ module DiscourseAi
:description,
:script,
:summary,
parameters: [:name, :type, :description, :required, :enum, enum_values: []],
parameters: [:name, :type, :description, :required, enum: []],
)
end
end
@@ -89,18 +89,26 @@ module DiscourseAi
end
end
def explain
def stream_suggestion
post_id = get_post_param!
term_to_explain = get_text_param!
text = get_text_param!
post = Post.includes(:topic).find_by(id: post_id)
prompt = CompletionPrompt.find_by(id: params[:mode])
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
raise Discourse::InvalidParameters.new(:post_id) unless post
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end
Jobs.enqueue(
:stream_post_helper,
post_id: post.id,
user_id: current_user.id,
term_to_explain: term_to_explain,
text: text,
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
)
render json: { success: true }, status: 200
@@ -157,9 +165,13 @@ module DiscourseAi
end
def ensure_can_request_suggestions
if !current_user.in_any_groups?(SiteSetting.ai_helper_allowed_groups_map)
raise Discourse::InvalidAccess
end
allowed_groups =
(
SiteSetting.composer_ai_helper_allowed_groups_map |
SiteSetting.post_ai_helper_allowed_groups_map
)
raise Discourse::InvalidAccess if !current_user.in_any_groups?(allowed_groups)
end
end
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
@@ -14,19 +14,32 @@ module DiscourseAi
RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user
opts = params.permit(:skip_age_check)
skip_age_check = opts[:skip_age_check] == "true"
if params[:stream] && current_user
cached_summary = DiscourseAi::TopicSummarization.cached_summary(topic, current_user)
if cached_summary && !skip_age_check
render_serialized(cached_summary, AiTopicSummarySerializer)
return
end
Jobs.enqueue(
:stream_topic_ai_summary,
topic_id: topic.id,
user_id: current_user.id,
opts: opts.as_json,
skip_age_check: skip_age_check,
)
render json: success_json
else
hijack do
summary = DiscourseAi::TopicSummarization.summarize(topic, current_user, opts)
summary =
DiscourseAi::TopicSummarization.summarize(
topic,
current_user,
skip_age_check: skip_age_check,
)
render_serialized(summary, AiTopicSummarySerializer)
end
end
+13 -5
View File
@@ -7,27 +7,35 @@ module Jobs
def execute(args)
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
return unless user = User.find_by(id: args[:user_id])
return unless args[:term_to_explain]
return unless args[:text]
topic = post.topic
reply_to = post.reply_to_post
return unless user.guardian.can_see?(post)
prompt = CompletionPrompt.enabled_by_name("explain")
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
input = <<~TEXT
<term>#{args[:term_to_explain]}</term>
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
prompt.custom_instruction = args[:custom_prompt]
end
if prompt.name == "explain"
input = <<~TEXT
<term>#{args[:text]}</term>
<context>#{post.raw}</context>
<topic>#{topic.title}</topic>
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
TEXT
else
input = args[:text]
end
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
prompt,
input,
user,
"/discourse-ai/ai-helper/explain/#{post.id}",
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}",
)
end
end
+3 -3
View File
@@ -14,15 +14,15 @@ module Jobs
guardian = Guardian.new(user)
return unless guardian.can_see?(topic)
opts = args[:opts] || {}
skip_age_check = !!args[:skip_age_check]
streamed_summary = +""
start = Time.now
summary =
DiscourseAi::TopicSummarization
.new(strategy)
.summarize(topic, user, opts) do |partial_summary|
.new(strategy, topic, user)
.summarize(skip_age_check: skip_age_check) do |partial_summary|
streamed_summary << partial_summary
# Throttle updates.
@@ -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
+38 -38
View File
@@ -2,59 +2,41 @@
class LlmModel < ActiveRecord::Base
FIRST_BOT_USER_ID = -1200
RESERVED_VLLM_SRV_URL = "https://vllm.shadowed-by-srv.invalid"
BEDROCK_PROVIDER_NAME = "aws_bedrock"
belongs_to :user
validates :url, exclusion: { in: [RESERVED_VLLM_SRV_URL] }, if: :url_changed?
def self.seed_srv_backed_model
srv = SiteSetting.ai_vllm_endpoint_srv
srv_model = find_by(url: RESERVED_VLLM_SRV_URL)
if srv.present?
if srv_model.present?
current_key = SiteSetting.ai_vllm_api_key
srv_model.update!(api_key: current_key) if current_key != srv_model.api_key
else
record =
new(
display_name: "vLLM SRV LLM",
name: "mistralai/Mixtral-8x7B-Instruct-v0.1",
provider: "vllm",
tokenizer: "DiscourseAi::Tokenizer::MixtralTokenizer",
url: RESERVED_VLLM_SRV_URL,
max_prompt_tokens: 8000,
api_key: SiteSetting.ai_vllm_api_key,
)
record.save(validate: false) # Ignore reserved URL validation
end
else
# Clean up companion users
srv_model&.enabled_chat_bot = false
srv_model&.toggle_companion_user
srv_model&.destroy!
end
end
validates :display_name, presence: true, length: { maximum: 100 }
validates :tokenizer, presence: true, inclusion: DiscourseAi::Completions::Llm.tokenizer_names
validates :provider, presence: true, inclusion: DiscourseAi::Completions::Llm.provider_names
validates :url, presence: true, unless: -> { provider == BEDROCK_PROVIDER_NAME }
validates_presence_of :name, :api_key
validates :max_prompt_tokens, numericality: { greater_than: 0 }
validate :required_provider_params
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
def to_llm
DiscourseAi::Completions::Llm.proxy_from_obj(self)
DiscourseAi::Completions::Llm.proxy("custom:#{id}")
end
def toggle_companion_user
@@ -106,6 +88,22 @@ class LlmModel < ActiveRecord::Base
def lookup_custom_param(key)
provider_params&.dig(key)
end
def seeded?
id < 0
end
private
def required_provider_params
return if provider != BEDROCK_PROVIDER_NAME
%w[access_key_id region].each do |field|
if lookup_custom_param(field).blank?
errors.add(:base, I18n.t("discourse_ai.llm_models.missing_provider_param", param: field))
end
end
end
end
# == Schema Information
@@ -124,4 +122,6 @@ end
# api_key :string
# user_id :integer
# enabled_chat_bot :boolean default(FALSE), not null
# provider_params :jsonb
# vision_enabled :boolean default(FALSE), not null
#
@@ -1,7 +1,12 @@
# frozen_string_literal: true
class AiTopicSummarySerializer < ApplicationSerializer
attributes :summarized_text, :algorithm, :outdated, :can_regenerate, :new_posts_since_summary
attributes :summarized_text,
:algorithm,
:outdated,
:can_regenerate,
:new_posts_since_summary,
:updated_at
def can_regenerate
scope.can_request_summary?
+17 -4
View File
@@ -12,12 +12,25 @@ class LlmModelSerializer < ApplicationSerializer
:api_key,
:url,
:enabled_chat_bot,
:shadowed_by_srv,
:provider_params
:provider_params,
:vision_enabled,
:used_by
has_one :user, serializer: BasicUserSerializer, embed: :object
def shadowed_by_srv
object.url == LlmModel::RESERVED_VLLM_SRV_URL
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
@@ -2,33 +2,36 @@
module DiscourseAi
class TopicSummarization
def self.summarize(topic, user, opts = {}, &on_partial_blk)
new(DiscourseAi::Summarization.default_strategy).summarize(topic, user, opts, &on_partial_blk)
def self.summarize(topic, user, skip_age_check: false, &on_partial_blk)
new(DiscourseAi::Summarization.default_strategy, topic, user).summarize(
skip_age_check: skip_age_check,
&on_partial_blk
)
end
def initialize(strategy)
def self.cached_summary(topic, user)
new(DiscourseAi::Summarization.default_strategy, topic, user).cached_summary
end
def initialize(strategy, topic, user)
@strategy = strategy
@topic = topic
@user = user
end
def summarize(topic, user, opts = {}, &on_partial_blk)
existing_summary = AiSummary.find_by(target: topic)
attr_reader :strategy, :topic, :user
def cached_summary
existing_summary
end
def summarize(skip_age_check: false, &on_partial_blk)
# Existing summary shouldn't be nil in this scenario because the controller checks its existence.
return if !user && !existing_summary
targets_data = summary_targets(topic).pluck(:post_number, :raw, :username)
return existing_summary if use_cached?(skip_age_check)
current_topic_sha = build_sha(targets_data.map(&:first))
can_summarize = Guardian.new(user).can_request_summary?
if use_cached?(existing_summary, can_summarize, current_topic_sha, !!opts[:skip_age_check])
# It's important that we signal a cached summary is outdated
existing_summary.mark_as_outdated if new_targets?(existing_summary, current_topic_sha)
return existing_summary
end
delete_cached_summaries_of(topic) if existing_summary
delete_cached_summaries! if existing_summary
content = {
resource_path: "#{Discourse.base_path}/t/-/#{topic.id}",
@@ -36,7 +39,7 @@ module DiscourseAi
contents: [],
}
targets_data.map do |(pn, raw, username)|
summary_targets_data.map do |(pn, raw, username)|
raw_text = raw
if pn == 1 && topic.topic_embed&.embed_content_cache.present?
@@ -47,19 +50,34 @@ module DiscourseAi
end
summarization_result = strategy.summarize(content, user, &on_partial_blk)
cache_summary(summarization_result, targets_data.map(&:first), topic)
cache_summary(summarization_result)
end
def summary_targets(topic)
topic.has_summary? ? best_replies(topic) : pick_selection(topic)
def summary_targets
topic.has_summary? ? best_replies : pick_selection
end
private
attr_reader :strategy
def summary_sha
@summary_sha ||= build_sha(summary_targets_data.map(&:first))
end
def best_replies(topic)
def summary_targets_data
@summary_targets_data ||= summary_targets.pluck(:post_number, :raw, :username)
end
def existing_summary
if !defined?(@existing_summary)
@existing_summary = AiSummary.find_by(target: topic)
if @existing_summary && existing_summary.original_content_sha != summary_sha
@existing_summary.mark_as_outdated
end
end
@existing_summary
end
def best_replies
Post
.summary(topic.id)
.where("post_type = ?", Post.types[:regular])
@@ -68,7 +86,7 @@ module DiscourseAi
.order(:post_number)
end
def pick_selection(topic)
def pick_selection
posts =
Post
.where(topic_id: topic.id)
@@ -87,31 +105,34 @@ module DiscourseAi
.order(:post_number)
end
def delete_cached_summaries_of(topic)
def delete_cached_summaries!
AiSummary.where(target: topic).destroy_all
end
# For users without permissions to generate a summary or fresh summaries, we return what we have cached.
def use_cached?(existing_summary, can_summarize, current_sha, skip_age_check)
def use_cached?(skip_age_check)
can_summarize = Guardian.new(user).can_request_summary?
existing_summary &&
!(
can_summarize && new_targets?(existing_summary, current_sha) &&
can_summarize && new_targets? &&
(skip_age_check || existing_summary.created_at < 1.hour.ago)
)
end
def new_targets?(summary, current_sha)
summary.original_content_sha != current_sha
def new_targets?
existing_summary&.original_content_sha != summary_sha
end
def cache_summary(result, post_numbers, topic)
def cache_summary(result)
post_numbers = summary_targets_data.map(&:first)
cached_summary =
AiSummary.create!(
target: topic,
algorithm: strategy.display_name,
content_range: (post_numbers.first..post_numbers.last),
summarized_text: result[:summary],
original_content_sha: build_sha(post_numbers),
original_content_sha: summary_sha,
)
cached_summary
@@ -13,7 +13,8 @@ export default class AiLlm extends RestModel {
"url",
"api_key",
"enabled_chat_bot",
"provider_params"
"provider_params",
"vision_enabled"
);
}
@@ -20,22 +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);
if (parameter.enum_values) {
parameter.enumValues = new TrackedArray(parameter.enum_values);
delete parameter.enum_values;
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);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
const AiHelperButtonGroup = <template>
<ul class="ai-helper-button-group" ...attributes>
{{#each @buttons as |button|}}
<li>
<DButton
@icon={{button.icon}}
@label={{button.label}}
@action={{button.action}}
class={{concatClass "btn-flat" button.classes}}
/>
</li>
{{/each}}
</ul>
</template>;
export default AiHelperButtonGroup;
@@ -1,38 +1,40 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import autoFocus from "discourse/modifiers/auto-focus";
import i18n from "discourse-common/helpers/i18n";
import not from "truth-helpers/helpers/not";
export default class AiHelperCustomPrompt extends Component {
@tracked _customPromptInput;
@action
setupCustomPrompt() {
this._customPromptInput = document.querySelector(
".ai-custom-prompt__input"
);
this._customPromptInput.focus();
}
@action
sendInput() {
sendInput(event) {
if (event.key !== "Enter") {
return;
}
return this.args.submit(this.args.promptArgs);
}
<template>
<div class="ai-custom-prompt" {{didInsert this.setupCustomPrompt}}>
<Input
@value={{@value}}
<div class="ai-custom-prompt">
<input
{{on "input" (withEventValue (fn (mut @value)))}}
{{on "keydown" this.sendInput}}
value={{@value}}
placeholder={{i18n
"discourse_ai.ai_helper.context_menu.custom_prompt.placeholder"
}}
class="ai-custom-prompt__input"
@enter={{this.sendInput}}
type="text"
{{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus"
because built in autofocus doesn't work consistently when component is
invoked twice separetly without a page refresh.
(i.e. trigger in post AI helper followed by trigger in composer AI helper)
--}}
{{autoFocus}}
/>
<DButton
@@ -2,7 +2,7 @@ import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n";
const AiHelperLoading = <template>
<div class="ai-helper-context-menu__loading">
<div class="ai-helper-loading">
<div class="dot-falling"></div>
<span>
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
@@ -0,0 +1,31 @@
import { fn } from "@ember/helper";
import DButton from "discourse/components/d-button";
import eq from "truth-helpers/helpers/eq";
import AiHelperCustomPrompt from "../components/ai-helper-custom-prompt";
const AiHelperOptionsList = <template>
<ul class="ai-helper-options">
{{#each @options as |option|}}
{{#if (eq option.name "custom_prompt")}}
<AiHelperCustomPrompt
@value={{@customPromptValue}}
@promptArgs={{option}}
@submit={{@performAction}}
/>
{{else}}
<li data-name={{option.translated_name}} data-value={{option.id}}>
<DButton
@icon={{option.icon}}
@translatedLabel={{option.translated_name}}
@action={{fn @performAction option}}
data-name={{option.name}}
data-value={{option.id}}
class="btn-flat ai-helper-options__button"
/>
</li>
{{/if}}
{{/each}}
</ul>
</template>;
export default AiHelperOptionsList;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,13 +6,13 @@ import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { service } from "@ember/service";
import AceEditor from "discourse/components/ace-editor";
import BackButton from "discourse/components/back-button";
import DButton from "discourse/components/d-button";
import DTooltip from "discourse/components/d-tooltip";
import withEventValue from "discourse/helpers/with-event-value";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
import AceEditor from "admin/components/ace-editor";
import ComboBox from "select-kit/components/combo-box";
import AiToolParameterEditor from "./ai-tool-parameter-editor";
import AiToolTestModal from "./modal/ai-tool-test-modal";
@@ -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;
}
@@ -71,13 +73,6 @@ export default class AiToolEditor extends Component {
"summary"
);
for (const p of data.parameters) {
if (p.enumValues) {
p.enum_values = p.enumValues;
delete p.enumValues;
}
}
await this.args.model.save(data);
this.toasts.success({
@@ -124,7 +119,7 @@ export default class AiToolEditor extends Component {
<template>
<BackButton
@route="adminPlugins.show.discourse-ai-tools"
@label="discourse_ai.ai_tool.back"
@label="discourse_ai.tools.back"
/>
<form
@@ -199,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"
@@ -24,8 +24,7 @@ export default class AiToolParameterEditor extends Component {
description: "",
type: "string",
required: false,
enum: false,
enumValues: null,
enum: null,
})
);
}
@@ -43,26 +42,30 @@ export default class AiToolParameterEditor extends Component {
@action
toggleEnum(parameter) {
parameter.enum = !parameter.enum;
if (!parameter.enum) {
parameter.enumValues = null;
if (parameter.enum) {
parameter.enum = null;
} else {
this.addEnumValue(parameter);
}
}
@action
addEnumValue(parameter) {
parameter.enumValues ||= new TrackedArray();
parameter.enumValues.push("");
parameter.enum ||= new TrackedArray();
parameter.enum.push("");
}
@action
removeEnumValue(parameter, index) {
parameter.enumValues.splice(index, 1);
parameter.enum.splice(index, 1);
if (parameter.enum.length === 0) {
parameter.enum = null;
}
}
@action
updateEnumValue(parameter, index, event) {
parameter.enumValues[index] = event.target.value;
parameter.enum[index] = event.target.value;
}
<template>
@@ -93,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>
@@ -102,6 +106,7 @@ export default class AiToolParameterEditor extends Component {
{{on "input" (fn this.toggleEnum parameter)}}
checked={{parameter.enum}}
type="checkbox"
class="parameter-row__enum-toggle"
/>
{{I18n.t "discourse_ai.tools.parameter_enum"}}
</label>
@@ -115,7 +120,7 @@ export default class AiToolParameterEditor extends Component {
{{#if parameter.enum}}
<div class="parameter-enum-values">
{{#each parameter.enumValues as |enumValue enumIndex|}}
{{#each parameter.enum as |enumValue enumIndex|}}
<div class="enum-value-row">
<input
{{on "change" (fn this.updateEnumValue parameter enumIndex)}}
@@ -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}}

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