Compare commits

...
Author SHA1 Message Date
Sam Saffron 399feafc4f WIP: migrate persona to agent 2025-05-29 15:40:46 +10:00
Guhyoun Nam ad5c48d9ae DEV: Add appEvents trigger for AI New Question button (#1379)
* DEV: Add appEvents trigger for AI New Question button

* appEvent name update
2025-05-28 13:26:37 -05:00
Keegan George d99c335dab DEV: Ensure enabling/disabling spam is set and logged (#1378)
Since we enable/disable `ai_spam_detection_enabled` setting in a custom Spam tab UI in AI, we want to ensure we retain the setting and logging features. To preserve that, we want to update the controller to use `SiteSetting.set_and_log` instead of setting the value directly.
2025-05-28 10:12:21 -07:00
Roman Rizzi 01e29ca5d8 FIX: Bump persona's examples length (#1377) 2025-05-28 14:01:44 -03:00
Keegan George 297c64c3b8 DEV: Unhide spam detection setting (#1374)
## 🔍 Overview
We want to unhide `ai_spam_detection_enabled` setting so that we can retain staff action log features. However, we also want to ensure users cannot enable spam detection without having `AiModerationSetting.spam` present in the database.

In this update we unhide the setting, but also add a validator to ensure the necessary configuration is in place before allowing the setting to be enabled.
2025-05-28 07:50:56 -07:00
David Taylor ca78b1a1c5 DEV: Patch Net::BufferedIO to help debug spec flakes (#1375)
Internal `/t/154170`
2025-05-28 10:24:07 +01:00
Discourse Translator Bot 39653aed22 Update translations (#1371) 2025-05-27 22:11:32 +02:00
Sam 6781ecd02e DEV: cleanup diff streaming (#1370)
This simplifies some of the internal logic and ensures it is a
bit more robust
2025-05-27 18:12:02 +10:00
Keegan George e264572597 FIX: Closing AI menu stops post audio from playing (#1369)
## 🔍 Overview
When you have a post with audio being played and you open and close the AI post helper menu, it re-renders the entire post DOM, causing the audio to be interrupted and stop playing.

The reason for this is because we highlight the selected text when opening the AI post helper menu and we replace the entire post back with the original post HTML when closing the menu. This fix ensures that we do not re-render the entire post DOM and instead only remove the highlighted section that was added.

## 🔗 Context
https://meta.discourse.org/t/ai-helper-interrupting-uploaded-mp3-audio-stream/366817?u=keegan
2025-05-26 10:24:06 -07:00
Sam 70b0db2871 FIX: improve researcher tool - fix topic filters (#1368)
* Small fix, reasoning is now available on Claude 4 models

* fix invalid filters should raise, topic filter not working

* fix spec so we are consistent
2025-05-26 16:00:44 +10:00
Sam 2d6ec5e1e6 FIX: apply diffs more consistently (#1367)
* FIX: apply diffs more consistently

1. Do escaping direct in diff streamer, that way HTML tags and other unsafe chars can be displayed and fixed
2. Add safeguard to ensure streaming always stops when it was terminated elsewhere

* lint

* bug unsubscribe should unsubscribe
2025-05-24 15:19:48 +10:00
Mark VanLandingham cead887480 FIX: Don't error when navigating from AI Bot topic to regular (#1366)
We were getting an error in this logic causing Ember to fail to render the non-bot-topic that we navigate to.

I believe this is because the getter of participants is re-calculating (due to this.header.topicInfo being updated) before the args to this connector changes. Adding some safe navigation here fixes the issue.
2025-05-23 13:30:08 -05:00
Jordan Vidrine adc92e1e75 UX: Allow rounded corners (#1365) 2025-05-23 10:42:55 -05:00
Roman Rizzi 0ce17a122f FIX: Correctly pass tool_choice when using Claude models. (#1364)
The `ClaudePrompt` object couldn't access the original prompt's tool_choice attribute, affecting both Anthropic and Bedrock.
2025-05-23 10:36:52 -03:00
Sam cf220c530c FIX: Improve MessageBus efficiency and correctly stop streaming (#1362)
* FIX: Improve MessageBus efficiency and correctly stop streaming

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

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

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

* composer suggestion needed a fix as well.

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

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

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

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

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

---------

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

For example:

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

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

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

Usage inside artifact is:

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

* update event name

* stop-forcing-custom-sidebar appevents trigger

* add force stop custom sidebar appEvents

* trigger name update

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

* some comment improvements

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

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

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

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

Also improve UI around research with more accurate info

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

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

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

* fix xml tools

* fix anthropic tools

* fix specs... a few more to go

* specs are passing

* FIX: coerce values for XML tool calls

* Update spec/lib/completions/tool_definition_spec.rb

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

---------

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

Key additions:

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

Infrastructure improvements:

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

Other improvements:

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

    Renames Researcher -> Web Researcher

This change makes our AI platform more stable while adding powerful research capabilities that can analyze forum trends and surface relevant content.
2025-05-14 12:36:16 +10:00
363 changed files with 6909 additions and 2856 deletions
@@ -0,0 +1,19 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsShowDiscourseAiAgentsEdit extends DiscourseRoute {
async model(params) {
const allAgents = this.modelFor(
"adminPlugins.show.discourse-ai-agents"
);
const id = parseInt(params.id, 10);
return allAgents.findBy("id", id);
}
setupController(controller, model) {
super.setupController(controller, model);
controller.set(
"allAgents",
this.modelFor("adminPlugins.show.discourse-ai-agents")
);
}
}
@@ -1,16 +1,16 @@
import { AUTO_GROUPS } from "discourse/lib/constants";
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsShowDiscourseAiPersonasNew extends DiscourseRoute {
export default class AdminPluginsShowDiscourseAiAgentsNew extends DiscourseRoute {
async model() {
const record = this.store.createRecord("ai-persona");
const record = this.store.createRecord("ai-agent");
record.set("allowed_group_ids", [AUTO_GROUPS.trust_level_0.id]);
record.set("rag_uploads", []);
// these match the defaults on the table
record.set("rag_chunk_tokens", 374);
record.set("rag_chunk_overlap_tokens", 10);
record.set("rag_conversation_chunks", 10);
record.set("allow_personal_messages", true);
record.set("allow_agentl_messages", true);
record.set("tool_details", false);
return record;
}
@@ -18,8 +18,8 @@ export default class AdminPluginsShowDiscourseAiPersonasNew extends DiscourseRou
setupController(controller, model) {
super.setupController(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
"allAgents",
this.modelFor("adminPlugins.show.discourse-ai-agents")
);
}
}
@@ -0,0 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiAiAgentsRoute extends DiscourseRoute {
model() {
return this.store.findAll("ai-agent");
}
}
@@ -1,19 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsShowDiscourseAiPersonasEdit extends DiscourseRoute {
async model(params) {
const allPersonas = this.modelFor(
"adminPlugins.show.discourse-ai-personas"
);
const id = parseInt(params.id, 10);
return allPersonas.findBy("id", id);
}
setupController(controller, model) {
super.setupController(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
);
}
}
@@ -1,7 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiAiPersonasRoute extends DiscourseRoute {
model() {
return this.store.findAll("ai-persona");
}
}
@@ -0,0 +1,4 @@
<AiAgentListEditor
@agents={{this.allAgents}}
@currentAgent={{this.model}}
/>
@@ -0,0 +1 @@
<AiAgentListEditor @agents={{this.model}} />
@@ -0,0 +1,4 @@
<AiAgentListEditor
@agents={{this.allAgents}}
@currentAgent={{this.model}}
/>
@@ -15,7 +15,7 @@ export default RouteTemplate(
const prefix = "discourse_ai.features.list.header";
return [
i18n(`${prefix}.name`),
i18n(`${prefix}.persona`),
i18n(`${prefix}.agent`),
i18n(`${prefix}.groups`),
"",
];
@@ -75,21 +75,21 @@ export default RouteTemplate(
</span>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__persona"
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__agent"
>
<DButton
class="btn-flat btn-small ai-feature-list__row-item-persona"
@translatedLabel={{feature.persona.name}}
@route="adminPlugins.show.discourse-ai-personas.edit"
@routeModels={{feature.persona.id}}
class="btn-flat btn-small ai-feature-list__row-item-agent"
@translatedLabel={{feature.agent.name}}
@route="adminPlugins.show.discourse-ai-agents.edit"
@routeModels={{feature.agent.id}}
/>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__groups"
>
{{#if (gt feature.persona.allowed_groups.length 0)}}
{{#if (gt feature.agent.allowed_groups.length 0)}}
<ul class="ai-feature-list__row-item-groups">
{{#each feature.persona.allowed_groups as |group|}}
{{#each feature.agent.allowed_groups as |group|}}
<li>{{group.name}}</li>
{{/each}}
</ul>
@@ -1,4 +0,0 @@
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
/>
@@ -1 +0,0 @@
<AiPersonaListEditor @personas={{this.model}} />
@@ -1,4 +0,0 @@
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
/>
@@ -1,4 +1,4 @@
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<section class="ai-agent-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@@ -1,4 +1,4 @@
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<section class="ai-agent-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@@ -2,20 +2,20 @@
module DiscourseAi
module Admin
class AiPersonasController < ::Admin::AdminController
class AiAgentsController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_persona, only: %i[edit update destroy create_user]
before_action :find_ai_agent, only: %i[edit update destroy create_user]
def index
ai_personas =
AiPersona.ordered.map do |persona|
ai_agents =
AiAgent.ordered.map do |agent|
# we use a special serializer here cause names and descriptions are
# localized for system personas
LocalizedAiPersonaSerializer.new(persona, root: false)
# localized for system agents
LocalizedAiAgentSerializer.new(agent, root: false)
end
tools =
DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
DiscourseAi::Agents::Agent.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false)
end
AiTool
@@ -36,7 +36,7 @@ module DiscourseAi
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
)
render json: {
ai_personas: ai_personas,
ai_agents: ai_agents,
meta: {
tools: tools,
llms: llms,
@@ -51,55 +51,55 @@ module DiscourseAi
end
def edit
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
render json: LocalizedAiAgentSerializer.new(@ai_agent)
end
def create
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
if ai_persona.save
RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids)
ai_agent = AiAgent.new(ai_agent_params.except(:rag_uploads))
if ai_agent.save
RagDocumentFragment.link_target_and_uploads(ai_agent, attached_upload_ids)
render json: {
ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false),
ai_agent: LocalizedAiAgentSerializer.new(ai_agent, root: false),
},
status: :created
else
render_json_error ai_persona
render_json_error ai_agent
end
end
def create_user
user = @ai_persona.create_user!
user = @ai_agent.create_user!
render json: BasicUserSerializer.new(user, root: "user")
end
def update
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids)
if @ai_agent.update(ai_agent_params.except(:rag_uploads))
RagDocumentFragment.update_target_uploads(@ai_agent, attached_upload_ids)
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
render json: LocalizedAiAgentSerializer.new(@ai_agent, root: false)
else
render_json_error @ai_persona
render_json_error @ai_agent
end
end
def destroy
if @ai_persona.destroy
if @ai_agent.destroy
head :no_content
else
render_json_error @ai_persona
render_json_error @ai_agent
end
end
def stream_reply
persona =
AiPersona.find_by(name: params[:persona_name]) ||
AiPersona.find_by(id: params[:persona_id])
return render_json_error(I18n.t("discourse_ai.errors.persona_not_found")) if persona.nil?
agent =
AiAgent.find_by(name: params[:agent_name]) ||
AiAgent.find_by(id: params[:agent_id])
return render_json_error(I18n.t("discourse_ai.errors.agent_not_found")) if agent.nil?
return render_json_error(I18n.t("discourse_ai.errors.persona_disabled")) if !persona.enabled
return render_json_error(I18n.t("discourse_ai.errors.agent_disabled")) if !agent.enabled
if persona.default_llm.blank?
if agent.default_llm.blank?
return render_json_error(I18n.t("discourse_ai.errors.no_default_llm"))
end
@@ -107,8 +107,8 @@ module DiscourseAi
return render_json_error(I18n.t("discourse_ai.errors.no_query_specified"))
end
if !persona.user_id
return render_json_error(I18n.t("discourse_ai.errors.no_user_for_persona"))
if !agent.user_id
return render_json_error(I18n.t("discourse_ai.errors.no_user_for_agent"))
end
if !params[:username] && !params[:user_unique_id]
@@ -142,7 +142,7 @@ module DiscourseAi
DiscourseAi::AiBot::ResponseHttpStreamer.queue_streamed_reply(
io: io,
persona: persona,
agent: agent,
user: user,
topic: topic,
query: params[:query].to_s,
@@ -178,17 +178,17 @@ module DiscourseAi
end
end
def find_ai_persona
@ai_persona = AiPersona.find(params[:id])
def find_ai_agent
@ai_agent = AiAgent.find(params[:id])
end
def attached_upload_ids
ai_persona_params[:rag_uploads].to_a.map { |h| h[:id] }
ai_agent_params[:rag_uploads].to_a.map { |h| h[:id] }
end
def ai_persona_params
def ai_agent_params
permitted =
params.require(:ai_persona).permit(
params.require(:ai_agent).permit(
:name,
:description,
:enabled,
@@ -209,7 +209,7 @@ module DiscourseAi
:allow_chat_channel_mentions,
:allow_chat_direct_messages,
:allow_topic_mentions,
:allow_personal_messages,
:allow_agentl_messages,
:tool_details,
:forced_tool_count,
:force_default_llm,
@@ -217,15 +217,15 @@ module DiscourseAi
rag_uploads: [:id],
)
if tools = params.dig(:ai_persona, :tools)
if tools = params.dig(:ai_agent, :tools)
permitted[:tools] = permit_tools(tools)
end
if response_format = params.dig(:ai_persona, :response_format)
if response_format = params.dig(:ai_agent, :response_format)
permitted[:response_format] = permit_response_format(response_format)
end
if examples = params.dig(:ai_persona, :examples)
if examples = params.dig(:ai_agent, :examples)
permitted[:examples] = permit_examples(examples)
end
@@ -17,19 +17,19 @@ module DiscourseAi
private
def serialize_features(features)
features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) }
features.map { |feature| feature.merge(agent: serialize_agent(feature[:agent])) }
end
def serialize_feature(feature)
return nil if feature.blank?
feature.merge(persona: serialize_persona(feature[:persona]))
feature.merge(agent: serialize_agent(feature[:agent]))
end
def serialize_persona(persona)
return nil if persona.blank?
def serialize_agent(agent)
return nil if agent.blank?
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
serialize_data(agent, AiFeaturesAgentSerializer, root: false)
end
end
end
@@ -50,7 +50,7 @@ module DiscourseAi
)
end
SiteSetting.ai_spam_detection_enabled = is_enabled
SiteSetting.set_and_log("ai_spam_detection_enabled", is_enabled, current_user)
end
render json: AiSpamSerializer.new(spam_config, root: false)
@@ -6,8 +6,8 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
def indexing_status_check
if params[:target_type] == "AiPersona"
@target = AiPersona.find(params[:target_id])
if params[:target_type] == "AiAgent"
@target = AiAgent.find(params[:target_id])
elsif params[:target_type] == "AiTool"
@target = AiTool.find(params[:target_id])
else
@@ -42,6 +42,21 @@ module DiscourseAi
<style>
#{artifact.css}
</style>
<script>
window._discourse_user_data = {
#{current_user ? "username: #{current_user.username.to_json}" : "username: null"}
};
window.discourseArtifactReady = new Promise(resolve => {
window._resolveArtifactData = resolve;
});
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'discourse-artifact-data') {
window.discourseArtifactData = event.data.dataset || {};
Object.assign(window.discourseArtifactData, window._discourse_user_data);
window._resolveArtifactData(window.discourseArtifactData);
}
});
</script>
</head>
<body>
#{artifact.html}
@@ -74,6 +89,19 @@ module DiscourseAi
</head>
<body>
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
<script>
document.querySelector('iframe').addEventListener('load', function() {
try {
const iframeWindow = this.contentWindow;
const message = { type: 'discourse-artifact-data', dataset: {} };
if (window.frameElement && window.frameElement.dataset) {
Object.assign(message.dataset, window.frameElement.dataset);
}
iframeWindow.postMessage(message, '*');
} catch (e) { console.error('Error passing data to artifact:', e); }
});
</script>
</body>
</html>
HTML
@@ -46,17 +46,17 @@ module DiscourseAi
end
def discover
ai_persona =
AiPersona
.all_personas(enabled_only: false)
.find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i }
ai_agent =
AiAgent
.all_agents(enabled_only: false)
.find { |agent| agent.id == SiteSetting.ai_bot_discover_agent.to_i }
if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a)
if ai_agent.nil? || !current_user.in_any_groups?(ai_agent.allowed_group_ids.to_a)
raise Discourse::InvalidAccess.new
end
if ai_persona.default_llm_id.blank?
render_json_error "Discover persona is missing a default LLM model.", status: 503
if ai_agent.default_llm_id.blank?
render_json_error "Discover agent is missing a default LLM model.", status: 503
return
end
@@ -77,7 +77,7 @@ module DiscourseAi
user = User.find(params[:user_id])
bot_user_id = AiPersona.find_by(id: SiteSetting.ai_bot_discover_persona).user_id
bot_user_id = AiAgent.find_by(id: SiteSetting.ai_bot_discover_agent).user_id
bot_username = User.find_by(id: bot_user_id).username
query = params[:query]
@@ -8,7 +8,7 @@ module DiscourseAi
before_action :require_site_settings!
skip_before_action :preload_json, :check_xhr, only: %i[show asset]
skip_before_action :verify_authenticity_token, only: ["asset"]
skip_before_action :redirect_to_login_if_required, :verify_authenticity_token, only: %i[asset]
def create
ensure_allowed_create!
@@ -124,6 +124,10 @@ module DiscourseAi
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end
# to stream we must have an appropriate client_id
# otherwise we may end up streaming the data to the wrong client
raise Discourse::InvalidParameters.new(:client_id) if params[:client_id].blank?
if location == "composer"
Jobs.enqueue(
:stream_composer_helper,
@@ -132,6 +136,7 @@ module DiscourseAi
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
force_default_locale: params[:force_default_locale] || false,
client_id: params[:client_id],
)
else
post_id = get_post_param!
@@ -146,6 +151,7 @@ module DiscourseAi
text: text,
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
client_id: params[:client_id],
)
end
+5 -5
View File
@@ -11,12 +11,12 @@ module ::Jobs
message = ::Chat::Message.find_by(id: args[:message_id])
return if message.blank?
personaClass =
DiscourseAi::Personas::Persona.find_by(id: args[:persona_id], user: message.user)
return if personaClass.blank?
agentClass =
DiscourseAi::Agents::Agent.find_by(id: args[:agent_id], user: message.user)
return if agentClass.blank?
user = User.find_by(id: personaClass.user_id)
bot = DiscourseAi::Personas::Bot.as(user, persona: personaClass.new)
user = User.find_by(id: agentClass.user_id)
bot = DiscourseAi::Agents::Bot.as(user, agent: agentClass.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to_chat_message(
message,
+6 -6
View File
@@ -7,18 +7,18 @@ module ::Jobs
def execute(args)
return unless bot_user = User.find_by(id: args[:bot_user_id])
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
persona_id = args[:persona_id]
agent_id = args[:agent_id]
begin
persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id)
raise DiscourseAi::Personas::Bot::BOT_NOT_FOUND if persona.nil?
agent = DiscourseAi::Agents::Agent.find_by(user: post.user, id: agent_id)
raise DiscourseAi::Agents::Bot::BOT_NOT_FOUND if agent.nil?
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new)
bot = DiscourseAi::Agents::Bot.as(bot_user, agent: agent.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to(post)
rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND
rescue DiscourseAi::Agents::Bot::BOT_NOT_FOUND
Rails.logger.warn(
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
"Bot not found for post #{post.id} - perhaps agent was deleted or bot was disabled",
)
end
end
@@ -8,6 +8,7 @@ module Jobs
return unless args[:prompt]
return unless user = User.find_by(id: args[:user_id])
return unless args[:text]
return unless args[:client_id]
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
@@ -21,6 +22,7 @@ module Jobs
user,
"/discourse-ai/ai-helper/stream_composer_suggestion",
force_default_locale: args[:force_default_locale],
client_id: args[:client_id],
)
end
end
+2 -2
View File
@@ -9,8 +9,8 @@ module Jobs
return unless SiteSetting.ai_discord_search_enabled
if SiteSetting.ai_discord_search_mode == "persona"
DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
if SiteSetting.ai_discord_search_mode == "agent"
DiscourseAi::Discord::Bot::AgentReplier.new(interaction).handle_interaction!
else
DiscourseAi::Discord::Bot::Search.new(interaction).handle_interaction!
end
+9 -9
View File
@@ -8,20 +8,20 @@ module Jobs
return if (user = User.find_by(id: args[:user_id])).nil?
return if (query = args[:query]).blank?
ai_persona_klass =
AiPersona
.all_personas(enabled_only: false)
.find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i }
ai_agent_klass =
AiAgent
.all_agents(enabled_only: false)
.find { |agent| agent.id == SiteSetting.ai_bot_discover_agent.to_i }
if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a)
if ai_agent_klass.nil? || !user.in_any_groups?(ai_agent_klass.allowed_group_ids.to_a)
return
end
return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil?
return if (llm_model = LlmModel.find_by(id: ai_agent_klass.default_llm_id)).nil?
bot =
DiscourseAi::Personas::Bot.as(
DiscourseAi::Agents::Bot.as(
Discourse.system_user,
persona: ai_persona_klass.new,
agent: ai_agent_klass.new,
model: llm_model,
)
@@ -31,7 +31,7 @@ module Jobs
base = { query: query, model_used: llm_model.display_name }
context =
DiscourseAi::Personas::BotContext.new(
DiscourseAi::Agents::BotContext.new(
messages: [{ type: :user, content: query }],
skip_tool_details: true,
)
@@ -1,16 +1,16 @@
# frozen_string_literal: true
class AiPersona < ActiveRecord::Base
class AiAgent < ActiveRecord::Base
# TODO remove this line 01-10-2025
self.ignored_columns = %i[default_llm question_consolidator_llm]
# places a hard limit, so per site we cache a maximum of 500 classes
MAX_PERSONAS_PER_SITE = 500
MAX_AGENTS_PER_SITE = 500
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
validates :description, presence: true, length: { maximum: 2000 }
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
validate :system_persona_unchangeable, on: :update, if: :system
validate :system_agent_unchangeable, on: :update, if: :system
validate :chat_preconditions
validate :allowed_seeded_model, if: :default_llm_id
validate :well_formated_examples
@@ -41,50 +41,50 @@ class AiPersona < ActiveRecord::Base
before_destroy :ensure_not_system
before_update :regenerate_rag_fragments
def self.persona_cache
@persona_cache ||= ::DiscourseAi::MultisiteHash.new("persona_cache")
def self.agent_cache
@agent_cache ||= ::DiscourseAi::MultisiteHash.new("agent_cache")
end
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
def self.all_personas(enabled_only: true)
persona_cache[:value] ||= AiPersona
def self.all_agents(enabled_only: true)
agent_cache[:value] ||= AiAgent
.ordered
.all
.limit(MAX_PERSONAS_PER_SITE)
.limit(MAX_AGENTS_PER_SITE)
.map(&:class_instance)
if enabled_only
persona_cache[:value].select { |p| p.enabled }
agent_cache[:value].select { |p| p.enabled }
else
persona_cache[:value]
agent_cache[:value]
end
end
def self.persona_users(user: nil)
persona_users =
persona_cache[:persona_users] ||= AiPersona
def self.agent_users(user: nil)
agent_users =
agent_cache[:agent_users] ||= AiAgent
.where(enabled: true)
.joins(:user)
.map do |persona|
.map do |agent|
{
id: persona.id,
user_id: persona.user_id,
username: persona.user.username_lower,
allowed_group_ids: persona.allowed_group_ids,
default_llm_id: persona.default_llm_id,
force_default_llm: persona.force_default_llm,
allow_chat_channel_mentions: persona.allow_chat_channel_mentions,
allow_chat_direct_messages: persona.allow_chat_direct_messages,
allow_topic_mentions: persona.allow_topic_mentions,
allow_personal_messages: persona.allow_personal_messages,
id: agent.id,
user_id: agent.user_id,
username: agent.user.username_lower,
allowed_group_ids: agent.allowed_group_ids,
default_llm_id: agent.default_llm_id,
force_default_llm: agent.force_default_llm,
allow_chat_channel_mentions: agent.allow_chat_channel_mentions,
allow_chat_direct_messages: agent.allow_chat_direct_messages,
allow_topic_mentions: agent.allow_topic_mentions,
allow_agentl_messages: agent.allow_agentl_messages,
}
end
if user
persona_users.select { |persona_user| user.in_any_groups?(persona_user[:allowed_group_ids]) }
agent_users.select { |agent_user| user.in_any_groups?(agent_user[:allowed_group_ids]) }
else
persona_users
agent_users
end
end
@@ -93,31 +93,31 @@ class AiPersona < ActiveRecord::Base
allow_chat_channel_mentions: false,
allow_chat_direct_messages: false,
allow_topic_mentions: false,
allow_personal_messages: false
allow_agentl_messages: false
)
index =
"modality-#{allow_chat_channel_mentions}-#{allow_chat_direct_messages}-#{allow_topic_mentions}-#{allow_personal_messages}"
"modality-#{allow_chat_channel_mentions}-#{allow_chat_direct_messages}-#{allow_topic_mentions}-#{allow_agentl_messages}"
personas =
persona_cache[index.to_sym] ||= persona_users.select do |persona|
next true if allow_chat_channel_mentions && persona[:allow_chat_channel_mentions]
next true if allow_chat_direct_messages && persona[:allow_chat_direct_messages]
next true if allow_topic_mentions && persona[:allow_topic_mentions]
next true if allow_personal_messages && persona[:allow_personal_messages]
agents =
agent_cache[index.to_sym] ||= agent_users.select do |agent|
next true if allow_chat_channel_mentions && agent[:allow_chat_channel_mentions]
next true if allow_chat_direct_messages && agent[:allow_chat_direct_messages]
next true if allow_topic_mentions && agent[:allow_topic_mentions]
next true if allow_agentl_messages && agent[:allow_agentl_messages]
false
end
if user
personas.select { |u| user.in_any_groups?(u[:allowed_group_ids]) }
agents.select { |u| user.in_any_groups?(u[:allowed_group_ids]) }
else
personas
agents
end
end
after_commit :bump_cache
def bump_cache
self.class.persona_cache.flush!
self.class.agent_cache.flush!
end
def tools_can_not_be_duplicated
@@ -138,7 +138,7 @@ class AiPersona < ActiveRecord::Base
end
if seen_tools.include?(inner_name)
errors.add(:tools, I18n.t("discourse_ai.ai_bot.personas.cannot_have_duplicate_tools"))
errors.add(:tools, I18n.t("discourse_ai.ai_bot.agents.cannot_have_duplicate_tools"))
break
else
seen_tools.add(inner_name)
@@ -154,7 +154,7 @@ class AiPersona < ActiveRecord::Base
.pluck(:tool_name)
.each do |tool_name|
if builtin_tool_names.include?(tool_name.downcase)
errors.add(:tools, I18n.t("discourse_ai.ai_bot.personas.cannot_have_duplicate_tools"))
errors.add(:tools, I18n.t("discourse_ai.ai_bot.agents.cannot_have_duplicate_tools"))
break
end
end
@@ -176,7 +176,7 @@ class AiPersona < ActiveRecord::Base
allow_chat_channel_mentions
allow_chat_direct_messages
allow_topic_mentions
allow_personal_messages
allow_agentl_messages
force_default_llm
name
description
@@ -208,14 +208,14 @@ class AiPersona < ActiveRecord::Base
if inner_name.start_with?("custom-")
custom_tool_id = inner_name.split("-", 2).last.to_i
if AiTool.exists?(id: custom_tool_id, enabled: true)
klass = DiscourseAi::Personas::Tools::Custom.class_instance(custom_tool_id)
klass = DiscourseAi::Agents::Tools::Custom.class_instance(custom_tool_id)
end
else
inner_name = inner_name.gsub("Tool", "")
inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name)
begin
klass = "DiscourseAi::Personas::Tools::#{inner_name}".constantize
klass = "DiscourseAi::Agents::Tools::#{inner_name}".constantize
options[klass] = current_options if current_options
rescue StandardError
end
@@ -225,14 +225,14 @@ class AiPersona < ActiveRecord::Base
klass
end
persona_class = DiscourseAi::Personas::Persona.system_personas_by_id[self.id]
if persona_class
agent_class = DiscourseAi::Agents::Agent.system_agents_by_id[self.id]
if agent_class
return(
# we need a new copy so we don't leak information
# across sites
Class.new(persona_class) do
Class.new(agent_class) do
# required for localization
define_singleton_method(:to_s) { persona_class.to_s }
define_singleton_method(:to_s) { agent_class.to_s }
instance_attributes.each do |key, value|
# description/name are localized
define_singleton_method(key) { value } if key != :description && key != :name
@@ -242,9 +242,9 @@ class AiPersona < ActiveRecord::Base
)
end
ai_persona_id = self.id
ai_agent_id = self.id
Class.new(DiscourseAi::Personas::Persona) do
Class.new(DiscourseAi::Agents::Agent) do
instance_attributes.each { |key, value| define_singleton_method(key) { value } }
define_singleton_method(:to_s) do
@@ -254,23 +254,24 @@ class AiPersona < ActiveRecord::Base
define_singleton_method(:inspect) { to_s }
define_method(:initialize) do |*args, **kwargs|
@ai_persona = AiPersona.find_by(id: ai_persona_id)
@ai_agent = AiAgent.find_by(id: ai_agent_id)
super(*args, **kwargs)
end
define_method(:tools) { tools }
define_method(:force_tool_use) { force_tool_use }
define_method(:forced_tool_count) { @ai_persona&.forced_tool_count }
define_method(:forced_tool_count) { @ai_agent&.forced_tool_count }
define_method(:options) { options }
define_method(:temperature) { @ai_persona&.temperature }
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(:examples) { @ai_persona&.examples }
define_method(:temperature) { @ai_agent&.temperature }
define_method(:top_p) { @ai_agent&.top_p }
define_method(:system_prompt) { @ai_agent&.system_prompt || "You are a helpful bot." }
define_method(:uploads) { @ai_agent&.uploads }
define_method(:response_format) { @ai_agent&.response_format }
define_method(:examples) { @ai_agent&.examples }
end
end
FIRST_PERSONA_USER_ID = -1200
FIRST_AGENT_USER_ID = -1200
def create_user!
raise "User already exists" if user_id && User.exists?(user_id)
@@ -278,7 +279,7 @@ class AiPersona < ActiveRecord::Base
# find the first id smaller than FIRST_USER_ID that is not taken
id = nil
id = DB.query_single(<<~SQL, FIRST_PERSONA_USER_ID, FIRST_PERSONA_USER_ID - 200).first
id = DB.query_single(<<~SQL, FIRST_AGENT_USER_ID, FIRST_AGENT_USER_ID - 200).first
WITH seq AS (
SELECT generate_series(?, ?, -1) AS id
)
@@ -322,12 +323,12 @@ class AiPersona < ActiveRecord::Base
allow_chat_channel_mentions || allow_chat_direct_messages || allow_topic_mentions ||
force_default_llm
) && !default_llm_id
errors.add(:default_llm, I18n.t("discourse_ai.ai_bot.personas.default_llm_required"))
errors.add(:default_llm, I18n.t("discourse_ai.ai_bot.agents.default_llm_required"))
end
end
def system_persona_unchangeable
error_msg = I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona")
def system_agent_unchangeable
error_msg = I18n.t("discourse_ai.ai_bot.agents.cannot_edit_system_agent")
if top_p_changed? || temperature_changed? || system_prompt_changed? || name_changed? ||
description_changed?
@@ -355,7 +356,7 @@ class AiPersona < ActiveRecord::Base
def ensure_not_system
if system
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_delete_system_persona"))
errors.add(:base, I18n.t("discourse_ai.ai_bot.agents.cannot_delete_system_agent"))
throw :abort
end
end
@@ -379,13 +380,13 @@ class AiPersona < ActiveRecord::Base
return
end
errors.add(:examples, I18n.t("discourse_ai.personas.malformed_examples"))
errors.add(:examples, I18n.t("discourse_ai.agents.malformed_examples"))
end
end
# == Schema Information
#
# Table name: ai_personas
# Table name: ai_agents
#
# id :bigint not null, primary key
# name :string(100) not null
@@ -413,7 +414,7 @@ end
# allow_chat_channel_mentions :boolean default(FALSE), not null
# allow_chat_direct_messages :boolean default(FALSE), not null
# allow_topic_mentions :boolean default(FALSE), not null
# allow_personal_messages :boolean default(TRUE), not null
# allow_agentl_messages :boolean default(TRUE), not null
# force_default_llm :boolean default(FALSE), not null
# rag_llm_model_id :bigint
# default_llm_id :bigint
@@ -423,5 +424,5 @@ end
#
# Indexes
#
# index_ai_personas_on_name (name) UNIQUE
# index_ai_agents_on_name (name) UNIQUE
#
+9 -9
View File
@@ -36,7 +36,7 @@ class AiTool < ActiveRecord::Base
end
def runner(parameters, llm:, bot_user:, context: nil)
DiscourseAi::Personas::ToolRunner.new(
DiscourseAi::Agents::ToolRunner.new(
parameters: parameters,
llm: llm,
bot_user: bot_user,
@@ -45,10 +45,10 @@ class AiTool < ActiveRecord::Base
)
end
after_commit :bump_persona_cache
after_commit :bump_agent_cache
def bump_persona_cache
AiPersona.persona_cache.flush!
def bump_agent_cache
AiAgent.agent_cache.flush!
end
def regenerate_rag_fragments
@@ -176,11 +176,11 @@ class AiTool < ActiveRecord::Base
* user_id_or_username (number | string): The ID or username of the user.
* Returns: Object (User details using UserSerializer structure) or null if not found.
*
* discourse.getPersona(name): Gets an object representing another AI Persona configured on the site.
* discourse.getAgent(name): Gets an object representing another AI Agent configured on the site.
* Parameters:
* name (string): The name of the target persona.
* Returns: Object { respondTo: function(params) } or null if persona not found.
* respondTo(params): Instructs the target persona to generate a response within the current context (e.g., replying to the same post or chat message).
* name (string): The name of the target agent.
* Returns: Object { respondTo: function(params) } or null if agent not found.
* respondTo(params): Instructs the target agent to generate a response within the current context (e.g., replying to the same post or chat message).
* Parameters:
* params (Object, optional): { instructions: string, whisper: boolean }
* Returns: { success: boolean, post_id?: number, post_number?: number, message_id?: number } or { error: string }
@@ -201,7 +201,7 @@ class AiTool < ActiveRecord::Base
* private_message (boolean): Whether the context is a private message (in Post context).
* message_id (number): ID of the chat message triggering the tool (if in Chat context).
* channel_id (number): ID of the chat channel (if in Chat context).
* user (Object): Details of the user invoking the tool/persona (structure may vary, often null or SystemUser details unless explicitly passed).
* user (Object): Details of the user invoking the tool/agent (structure may vary, often null or SystemUser details unless explicitly passed).
* participants (string): Comma-separated list of usernames in a PM (if applicable).
* // ... other potential context-specific properties added by the calling environment.
*
+4 -4
View File
@@ -2,7 +2,7 @@
class RagDocumentFragment < ActiveRecord::Base
# TODO Jan 2025 - remove
self.ignored_columns = %i[ai_persona_id]
self.ignored_columns = %i[ai_agent_id]
belongs_to :upload
belongs_to :target, polymorphic: true
@@ -38,7 +38,7 @@ class RagDocumentFragment < ActiveRecord::Base
end
end
def indexing_status(persona, uploads)
def indexing_status(agent, uploads)
embeddings_table = DiscourseAi::Embeddings::Schema.for(self).table
results =
@@ -56,8 +56,8 @@ class RagDocumentFragment < ActiveRecord::Base
WHERE uploads.id IN (:upload_ids)
GROUP BY uploads.id
SQL
target_id: persona.id,
target_type: persona.class.to_s,
target_id: agent.id,
target_type: agent.class.to_s,
upload_ids: uploads.map(&:id),
)

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