Compare commits

...
Author SHA1 Message Date
Nat 30e258704e DEV: Increase chunk size before splitting content for translation 2025-06-18 23:53:10 +08:00
Sam 32dc45ba4f FIX: never block spam scanning user (#1437)
Previously staff and bots would get scanned if TL was low
Additionally if somehow spam scanner user was blocked
(deactivated, silenced, banned) it would stop the feature from working

This adds an override that ensures unconditionally the user is setup correctly prior to scanning
2025-06-17 14:51:27 +10:00
Rafael dos Santos Silva bc8e57d7e8 DEV: Move title suggestion to an array (#1435) 2025-06-16 18:06:54 -03:00
Kris 24416c5b87 UX: focus conversation input on route transition and button click (#1404) 2025-06-13 17:45:51 -04:00
Natalie Tay b5e8277083 DEV: Move AI translation feature into an AI Feature (#1424)
This PR moves translations into an AI Feature

See https://github.com/discourse/discourse-ai/pull/1424 for screenshots
2025-06-13 10:17:27 +08:00
Keegan George 9be1049de6 DEV: Log AI related configuration to staff action log (#1416)
is update adds logging for changes made in the AI admin panel. When making configuration changes to Embeddings, LLMs, Personas, Tools, or Spam that aren't site setting related, changes will now be logged in Admin > Logs & Screening. This will help admins debug issues related to AI. In this update a helper lib is created called `AiStaffActionLogger` which can be easily used in the future to add logging support for any other admin config we need logged for AI.
2025-06-12 12:39:58 -07:00
Natalie Tay fc83bed7cd FIX: When allowing private content translation, only translate group PMs and not personal PMs (#1432)
We want to avoid translating PMs that are not group PMs. This condition is applied when `SiteSetting.ai_translation_backfill_limit_to_public_content = false`
2025-06-13 00:55:52 +08:00
Roman Rizzi 9b7f1e6ee9 FIX: Helper wasn't working when the persona doesn't use structured output (#1433) 2025-06-12 12:33:12 -03:00
Kris 22da440130 UX: add features to persona list and other style updates (#1405) 2025-06-12 08:23:10 -04:00
Sam 02bc9f645e FEATURE: hybrid artifact security mode (#1431)
In hybrid mode ai artifacts can optionally automatically run.

This is useful for cases where you may want to embed a survey and so on.

Additionally, artifacts now allow for better fidelity around display:

<div class="ai-artifact" data-ai-artifact-id="501" data-ai-artifact-height="300px" data-ai-artifact-autorun data-ai-artifact-seamless></div>

User can supply height and seamless mode to be seamlessly rendered with no box shadow and show full screen button.
2025-06-12 20:04:48 +10:00
Joffrey JAFFEUX b5a2ee31ab DEV: adds pointerup event to our select text helper (#1429)
This is needed for https://github.com/discourse/discourse/pull/33143

Ideally we should move all these helpers to core, or improve the ones in core.
2025-06-12 10:33:14 +02:00
SamandAlan Guo Xiang Tan ed311de937 FIX: various bugs in AI interface (#1430)
* FIX: improve transition logic in forms

previously back button would take you back to the /new route

* FIX: enum selection not working for persona tools

* seed information correctly in the DB

* fix broken spec

* Update assets/javascripts/discourse/components/ai-tool-editor-form.gjs

Co-authored-by: Alan Guo Xiang Tan <[email protected]>

---------

Co-authored-by: Alan Guo Xiang Tan <[email protected]>
2025-06-12 13:50:52 +10:00
Roman Rizzi 8c8fd969ef FIX: Don't check for #blank? when manipulating chunks (#1428) 2025-06-11 20:38:58 -03:00
Joffrey JAFFEUX 26217e51f9 DEV: a real selection change has a pointerup event (#1427)
This is needed for https://github.com/discourse/discourse/pull/33143 as we now rely on this pointerup event.
2025-06-12 00:59:21 +02:00
Keegan George 442530a154 UX: Show error and ability to try again when no suggestions (#1426)
## 🔍  Overview
When the title suggestions return no suggestions, there is no indication in the UI. In the tag suggester we show a toast when there aren't any suggestions but the request was a success. In this update we make a similar UI indication with a toast for both category and title suggestions. Additionally, for all suggestions we add a "Try again" button to the toast so that suggestions can be generated again if the results yield nothing the first time.
2025-06-11 12:05:10 -07:00
Sam a907bc891a FIX: improve admin api for artifact key values (#1425)
Previously we had a logic error and were showing admins keys
that are not theirs when querying for all keys

This makes the API cleaner, to get all results you need to be explicit always
2025-06-11 19:33:34 +10:00
Sam d97307e99b FEATURE: optionally support OpenAI responses API (#1423)
OpenAI ship a new API for completions called "Responses API"

Certain models (o3-pro) require this API.
Additionally certain features are only made available to the new API.

This allow enabling it per LLM.

see: https://platform.openai.com/docs/api-reference/responses
2025-06-11 17:12:25 +10:00
Natalie Tay 35d62a659b FIX: Skip edits if localization exists (#1422)
We will fine tune updating an outdated localization in the future. For now we are seeing that quick edits are happening and we need to prevent the job from being too trigger-happy.
2025-06-11 11:00:22 +08:00
Discourse Translator Bot f99309d1e1 Update translations (#1419) 2025-06-10 23:27:57 +02:00
Sam fdf0ff8a25 FEATURE: persistent key-value storage for AI Artifacts (#1417)
Introduces a persistent, user-scoped key-value storage system for
AI Artifacts, enabling them to be stateful and interactive. This
transforms artifacts from static content into mini-applications that can
save user input, preferences, and other data.

The core components of this feature are:

1.  **Model and API**:
    - A new `AiArtifactKeyValue` model and corresponding database table to
      store data associated with a user and an artifact.
    - A new `ArtifactKeyValuesController` provides a RESTful API for
      CRUD operations (`index`, `set`, `destroy`) on the key-value data.
    - Permissions are enforced: users can only modify their own data but
      can view public data from other users.

2.  **Secure JavaScript Bridge**:
    - A `postMessage` communication bridge is established between the
      sandboxed artifact `iframe` and the parent Discourse window.
    - A JavaScript API is exposed to the artifact as `window.discourseArtifact`
      with async methods: `get(key)`, `set(key, value, options)`,
      `delete(key)`, and `index(filter)`.
    - The parent window handles these requests, makes authenticated calls to the
      new controller, and returns the results to the iframe. This ensures
      security by keeping untrusted JS isolated.

3.  **AI Tool Integration**:
    - The `create_artifact` tool is updated with a `requires_storage`
      boolean parameter.
    - If an artifact requires storage, its metadata is flagged, and the
      system prompt for the code-generating AI is augmented with detailed
      documentation for the new storage API.

4.  **Configuration**:
    - Adds hidden site settings `ai_artifact_kv_value_max_length` and
      `ai_artifact_max_keys_per_user_per_artifact` for throttling.

This also includes a minor fix to use `jsonb_set` when updating
artifact metadata, ensuring other metadata fields are preserved.
2025-06-11 06:59:46 +10:00
Roman Rizzi f7e0ea888d DEV: Use a PORO to represent modules/features. (#1421)
Additional changes:

Adds a "#features" method in AiPersona to find which features are using that persona.
Serializes a basic version of a LlmModel in the persona's "#default_llm" serializer attribute.
2025-06-10 14:37:53 -03:00
Rafael dos Santos Silva b54db133cd FIX: No need for XML in gists responses anymore (#1420) 2025-06-10 14:21:31 -03:00
Guhyoun Nam cb94fa5e1c DEV: Add condition for force-conversations-sidebar AppEvent (#1418) 2025-06-10 11:29:38 -05:00
Roman Rizzi 98afd7f8c3 FEATURE: Display features that rely on multiple personas. (#1411)
* FEATURE: Display features that rely on multiple personas.

This change makes the previously hidden feature page visible while displaying features, like the AI helper, which relies on multiple personas.

* Fix system specs
2025-06-09 16:13:09 -03:00
Keegan George 33fd6801e5 DEV: Add back validator for Spam setting (#1415)
## 🔍 Overview
This update re-introduces the validator used on the `ai_spam_detection_enabled` setting. It was initially added here: https://github.com/discourse/discourse-ai/pull/1374 to prevent Spam from being enabled without creating an `AiModerationSetting` value in the database. However, due to issues with backups/migrations we temporarily removed it here: https://github.com/discourse/discourse-ai/pull/1393. Now with some internal fixes, we can re-introduce it. We also update the validator so that it only validates when trying to turn on rather than when turning off too.
2025-06-06 10:56:36 -07:00
Natalie Tay 6827147362 DEV: Add topic and post id when using completions for traceability to AiApiAuditLog (#1414)
The AiApiAuditLog per translation event doesn't trace back easily to a post or topic.

This commit adds support to that, and also switches the translators to named arguments rather than positional arguments.
2025-06-06 23:24:24 +08:00
Natalie Tay 8a3a247b11 DEV: Also detect locale of categories and do not translate if already in the locale (#1413)
Previously I had omitted to add `locale` to the category, as categories tended to be just a single word, and I did not find it would be worth to carry locale information.

Due to certain LLMs that do poorer at translation, category descriptions got pretty messy. We added locale support here - https://github.com/discourse/discourse/pull/32962. 

This PR adds the automatic locale detection, and skips translating to the category's locale.
2025-06-06 22:41:48 +08:00
Sam 6817866de9 FEATURE: allow access to assigns from forum researcher (#1412)
* FEATURE: allow access to assigns from forum researcher

* FIX: should properly be checking for empty

* finish PR
2025-06-06 16:59:00 +10:00
Mark VanLandingham 7d96739aab DEV: Add body class on ai-bot-conversations page (#1410) 2025-06-05 10:29:40 -06:00
Roman Rizzi a68ab76eb6 FIX: Update topic summarization prompt to work better when using full names (#1409) 2025-06-05 12:28:29 -03:00
Martin Brennan 1fb358a9d0 UX: Style tweaks for RAG uploader and form width (#1407)
This commit changes the RAG uploader form elements to
be @format="full" instead of doing a hardcoded 500px width,
which was causing a horizontal scrollbar in the tools form
on mobile.

Also, it moves the 80% max width for the tools form into the
new viewport CSS API, and only applies it on desktop, because
this was also causing width issues on mobile.
2025-06-05 12:40:00 +10:00
Sam b3d78a6a10 FIX: when tool options are added they should be available (#1406)
Fixes a regression where tool option editor was not showing
all tools
2025-06-05 12:05:55 +10:00
Roman Rizzi c885e5697f review feedback 2025-06-04 14:23:00 -03:00
Roman Rizzi 0338dbea23 FEATURE: Use different personas to power AI helper features.
You can now edit each AI helper prompt individually through personas, limit access to specific groups, set different LLMs, etc.
2025-06-04 14:23:00 -03:00
David Taylor cab39839fd Revert "DEV: Patch Net::BufferedIO to help debug spec flakes (#1375)" (#1403)
This reverts commit ca78b1a1c5.

Problem resolved by https://github.com/discourse/discourse-perspective-api/pull/110
2025-06-04 14:13:45 +01:00
Jarek Radosz 2842295b10 DEV: Remove duplicated i18n entries (#1402)
Regressed in 3e74eea1e5
2025-06-04 15:13:12 +02:00
Sam 3e74eea1e5 FEATURE: add context and llm controls to researcher, fix username filter (#1401)
Adds context length controls to researcher (max tokens per post and batch)
Allow picking LLM for researcher
Fix bug where unicode usernames were not working
Fix documentation of OR logic
2025-06-04 16:39:43 +10:00
Kris 4f980d5514 FIX: always render "today" on top of conversation sidebar (#1400) 2025-06-03 14:52:12 -04:00
Discourse Translator Bot 59f4b66ede Update translations (#1395) 2025-06-03 17:37:22 +02:00
Kris fa51e9d948 REFACTOR: update AI conversation sidebar to use sidebar sections for date grouping (#1389) 2025-06-03 09:40:52 -05:00
Joffrey JAFFEUX 306fec2b24 FIX: edit-topic is not invisible on desktop (#1394)
Fix due to https://github.com/discourse/discourse/pull/32941
2025-06-03 16:30:19 +02:00
Keegan George 49fe986974 FIX: unable to scroll on mobile AI post helper results (#1396)
## 🔍 Overview

As reported on [Meta](https://meta.discourse.org/t/when-selecting-text-and-giving-ai-a-custom-prompt-based-on-the-text-the-popup-cannot-be-scrolled/368687?u=keegan), you are currently unable to scroll on long AI post helper results when on mobile. This update fixes that by allowing scroll back. No test as it's tricky to test scroll behavior.

## 🔗 Relevant Links
https://meta.discourse.org/t/when-selecting-text-and-giving-ai-a-custom-prompt-based-on-the-text-the-popup-cannot-be-scrolled/368687

## 📹 Screen Recording

### ← Before
https://github.com/user-attachments/assets/9dc6f6a2-b9d4-46a8-91ee-ad1ed6f47de0


### → After
https://github.com/user-attachments/assets/ca93dba1-eefb-4d03-9b47-c4a8ba291117
2025-06-03 06:34:22 -07:00
240 changed files with 7693 additions and 2364 deletions
+1
View File
@@ -1,3 +1,4 @@
< 3.5.0.beta6-dev: 3e74eea1e5e3143888d67a8d8a11206df214dc24
< 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
< 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
@@ -12,7 +12,7 @@ export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRo
const { site_settings } = await ajax("/admin/config/site_settings.json", {
data: {
filter_area: `ai-features/${currentFeature.ref}`,
filter_area: `ai-features/${currentFeature.module_name}`,
plugin: "discourse-ai",
category: "discourse_ai",
},
@@ -1,156 +0,0 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import RouteTemplate from "ember-route-template";
import { gt } from "truth-helpers";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import { i18n } from "discourse-i18n";
export default RouteTemplate(
class extends Component {
@service adminPluginNavManager;
get tableHeaders() {
const prefix = "discourse_ai.features.list.header";
return [
i18n(`${prefix}.name`),
i18n(`${prefix}.persona`),
i18n(`${prefix}.groups`),
"",
];
}
get configuredFeatures() {
return this.args.model.filter(
(feature) => feature.enable_setting.value === true
);
}
get unconfiguredFeatures() {
return this.args.model.filter(
(feature) => feature.enable_setting.value === false
);
}
<template>
<DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-features"
@label={{i18n "discourse_ai.features.short_title"}}
/>
<section class="ai-feature-list admin-detail">
<DPageSubheader
@titleLabel={{i18n "discourse_ai.features.short_title"}}
@descriptionLabel={{i18n "discourse_ai.features.description"}}
@learnMoreUrl="todo"
/>
{{#if (gt this.configuredFeatures.length 0)}}
<div class="ai-feature-list__configured-features">
<h3>{{i18n "discourse_ai.features.list.configured_features"}}</h3>
<table class="d-admin-table">
<thead>
<tr>
{{#each this.tableHeaders as |header|}}
<th>{{header}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.configuredFeatures as |feature|}}
<tr
class="ai-feature-list__row d-admin-row__content"
data-feature-name={{feature.name}}
>
<td class="d-admin-row__overview ai-feature-list__row-item">
<span class="ai-feature-list__row-item-name">
<strong>
{{feature.name}}
</strong>
</span>
<span class="ai-feature-list__row-item-description">
{{feature.description}}
</span>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__persona"
>
<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}}
/>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__groups"
>
{{#if (gt feature.persona.allowed_groups.length 0)}}
<ul class="ai-feature-list__row-item-groups">
{{#each feature.persona.allowed_groups as |group|}}
<li>{{group.name}}</li>
{{/each}}
</ul>
{{/if}}
</td>
<td class="d-admin-row_controls">
<DButton
class="btn-small edit"
@label="discourse_ai.features.list.edit"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
{{#if (gt this.unconfiguredFeatures.length 0)}}
<div class="ai-feature-list__unconfigured-features">
<h3>{{i18n "discourse_ai.features.list.unconfigured_features"}}</h3>
<table class="d-admin-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.features.list.header.name"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each this.unconfiguredFeatures as |feature|}}
<tr class="ai-feature-list__row d-admin-row__content">
<td class="d-admin-row__overview ai-feature-list__row-item">
<span class="ai-feature-list__row-item-name">
<strong>
{{feature.name}}
</strong>
</span>
<span class="ai-feature-list__row-item-description">
{{feature.description}}
</span>
</td>
<td class="d-admin-row_controls">
<DButton
class="btn-small"
@label="discourse_ai.features.list.set_up"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
</section>
</template>
}
);
@@ -0,0 +1 @@
<AiFeatures @features={{this.model}} />
@@ -40,6 +40,7 @@ module DiscourseAi
embedding_def = EmbeddingDefinition.new(ai_embeddings_params)
if embedding_def.save
log_ai_embedding_creation(embedding_def)
render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created
else
render_json_error embedding_def
@@ -55,7 +56,10 @@ module DiscourseAi
)
end
initial_attributes = embedding_def.attributes.dup
if embedding_def.update(ai_embeddings_params.except(:dimensions))
log_ai_embedding_update(embedding_def, initial_attributes)
render json: AiEmbeddingDefinitionSerializer.new(embedding_def)
else
render_json_error embedding_def
@@ -75,7 +79,16 @@ module DiscourseAi
return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409)
end
embedding_details = {
embedding_id: embedding_def.id,
display_name: embedding_def.display_name,
provider: embedding_def.provider,
dimensions: embedding_def.dimensions,
subject: embedding_def.display_name,
}
if embedding_def.destroy
log_ai_embedding_deletion(embedding_details)
head :no_content
else
render_json_error embedding_def
@@ -128,6 +141,60 @@ module DiscourseAi
permitted
end
def ai_embeddings_logger_fields
{
display_name: {
},
provider: {
},
dimensions: {
},
url: {
},
tokenizer_class: {
},
max_sequence_length: {
},
embed_prompt: {
type: :large_text,
},
search_prompt: {
type: :large_text,
},
matryoshka_dimensions: {
},
api_key: {
type: :sensitive,
},
# JSON fields should be tracked as simple changes
json_fields: [:provider_params],
}
end
def log_ai_embedding_creation(embedding_def)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
logger.log_creation("embedding", embedding_def, ai_embeddings_logger_fields, entity_details)
end
def log_ai_embedding_update(embedding_def, initial_attributes)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
logger.log_update(
"embedding",
embedding_def,
initial_attributes,
ai_embeddings_logger_fields,
entity_details,
)
end
def log_ai_embedding_deletion(embedding_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_deletion("embedding", embedding_details)
end
end
end
end
@@ -6,24 +6,40 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
def index
render json: serialize_features(DiscourseAi::Features.features)
render json: serialize_modules(DiscourseAi::Configuration::Module.all)
end
def edit
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i))
a_module = DiscourseAi::Configuration::Module.find_by(id: params[:id].to_i)
render json: serialize_module(a_module)
end
private
def serialize_features(features)
features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) }
def serialize_modules(modules)
modules.map { |a_module| serialize_module(a_module) }
end
def serialize_module(a_module)
return nil if a_module.blank?
{
id: a_module.id,
module_name: a_module.name,
module_enabled: a_module.enabled?,
features: a_module.features.map { |f| serialize_feature(f) },
}
end
def serialize_feature(feature)
return nil if feature.blank?
feature.merge(persona: serialize_persona(feature[:persona]))
{
name: feature.name,
persona: serialize_persona(persona_id_obj_hash[feature.persona_id]),
enabled: feature.enabled?,
}
end
def serialize_persona(persona)
@@ -31,6 +47,18 @@ module DiscourseAi
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
end
private
def persona_id_obj_hash
@persona_id_obj_hash ||=
begin
setting_names = DiscourseAi::Configuration::Feature.all_persona_setting_names
ids = setting_names.map { |sn| SiteSetting.public_send(sn) }
AiPersona.where(id: ids).index_by(&:id)
end
end
end
end
end
@@ -47,6 +47,7 @@ module DiscourseAi
if llm_model.save
llm_model.toggle_companion_user
log_llm_model_creation(llm_model)
render json: LlmModelSerializer.new(llm_model), status: :created
else
render_json_error llm_model
@@ -56,6 +57,10 @@ module DiscourseAi
def update
llm_model = LlmModel.find(params[:id])
# Capture initial state for logging
initial_attributes = llm_model.attributes.dup
initial_quotas = llm_model.llm_quotas.map(&:attributes)
if params[:ai_llm].key?(:llm_quotas)
if quota_params
existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id)
@@ -81,6 +86,7 @@ module DiscourseAi
if llm_model.update(ai_llm_params(updating: llm_model))
llm_model.toggle_companion_user
log_llm_model_update(llm_model, initial_attributes, initial_quotas)
render json: LlmModelSerializer.new(llm_model)
else
render_json_error llm_model
@@ -109,11 +115,20 @@ module DiscourseAi
)
end
# Capture model details for logging before destruction
model_details = {
model_id: llm_model.id,
display_name: llm_model.display_name,
name: llm_model.name,
provider: llm_model.provider,
}
# Clean up companion users
llm_model.enabled_chat_bot = false
llm_model.toggle_companion_user
if llm_model.destroy
log_llm_model_deletion(model_details)
head :no_content
else
render_json_error llm_model
@@ -190,6 +205,89 @@ module DiscourseAi
permitted
end
def ai_llm_logger_fields
{
display_name: {
},
name: {
},
provider: {
},
tokenizer: {
},
url: {
},
max_prompt_tokens: {
},
max_output_tokens: {
},
enabled_chat_bot: {
},
vision_enabled: {
},
api_key: {
type: :sensitive,
},
input_cost: {
},
output_cost: {
},
# JSON fields should be tracked as simple changes
json_fields: [:provider_params],
}
end
def log_llm_model_creation(llm_model)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
# Add quota information as a special case
if llm_model.llm_quotas.any?
entity_details[:quotas] = llm_model
.llm_quotas
.map do |quota|
"Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s"
end
.join("; ")
end
logger.log_creation("llm_model", llm_model, ai_llm_logger_fields, entity_details)
end
def log_llm_model_update(llm_model, initial_attributes, initial_quotas)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
# Track quota changes separately as they're a special case
current_quotas = llm_model.llm_quotas.reload.map(&:attributes)
if initial_quotas != current_quotas
initial_quota_summary =
initial_quotas
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
.join("; ")
current_quota_summary =
current_quotas
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
.join("; ")
entity_details[:quotas_changed] = true
entity_details[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}"
end
logger.log_update(
"llm_model",
llm_model,
initial_attributes,
ai_llm_logger_fields,
entity_details,
)
end
def log_llm_model_deletion(model_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
model_details[:subject] = model_details[:display_name]
logger.log_deletion("llm_model", model_details)
end
end
end
end
@@ -9,15 +9,16 @@ module DiscourseAi
def index
ai_personas =
AiPersona.ordered.map do |persona|
# we use a special serializer here cause names and descriptions are
# localized for system personas
LocalizedAiPersonaSerializer.new(persona, root: false)
end
AiPersona
.ordered
.includes(:user, :uploads)
.map { |persona| LocalizedAiPersonaSerializer.new(persona, root: false) }
tools =
DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false)
end
AiTool
.where(enabled: true)
.each do |tool|
@@ -31,10 +32,12 @@ module DiscourseAi
),
}
end
llms =
DiscourseAi::Configuration::LlmEnumerator.values_for_serialization(
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
)
render json: {
ai_personas: ai_personas,
meta: {
@@ -58,6 +61,7 @@ module DiscourseAi
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
if ai_persona.save
RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids)
log_ai_persona_creation(ai_persona)
render json: {
ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false),
@@ -74,8 +78,11 @@ module DiscourseAi
end
def update
initial_attributes = @ai_persona.attributes.dup
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids)
log_ai_persona_update(@ai_persona, initial_attributes)
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
else
@@ -84,7 +91,14 @@ module DiscourseAi
end
def destroy
persona_details = {
persona_id: @ai_persona.id,
name: @ai_persona.name,
description: @ai_persona.description,
}
if @ai_persona.destroy
log_ai_persona_deletion(persona_details)
head :no_content
else
render_json_error @ai_persona
@@ -261,6 +275,92 @@ module DiscourseAi
examples.map { |example_arr| example_arr.take(2).map(&:to_s) }
end
def ai_persona_logger_fields
{
name: {
},
description: {
},
enabled: {
},
priority: {
},
system_prompt: {
type: :large_text,
},
default_llm_id: {
},
temperature: {
},
top_p: {
},
user_id: {
},
max_context_posts: {
},
vision_enabled: {
},
vision_max_pixels: {
},
rag_chunk_tokens: {
},
rag_chunk_overlap_tokens: {
},
rag_conversation_chunks: {
},
rag_llm_model_id: {
},
question_consolidator_llm_id: {
},
allow_chat_channel_mentions: {
},
allow_chat_direct_messages: {
},
allow_topic_mentions: {
},
allow_personal_messages: {
},
tool_details: {
type: :large_text,
},
forced_tool_count: {
},
force_default_llm: {
},
# JSON fields
json_fields: %i[tools response_format examples allowed_group_ids],
}
end
def log_ai_persona_creation(ai_persona)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { persona_id: ai_persona.id, subject: ai_persona.name }
entity_details[:tools_count] = (ai_persona.tools || []).size
logger.log_creation("persona", ai_persona, ai_persona_logger_fields, entity_details)
end
def log_ai_persona_update(ai_persona, initial_attributes)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { persona_id: ai_persona.id, subject: ai_persona.name }
entity_details[:tools_count] = ai_persona.tools.size if ai_persona.tools.present?
logger.log_update(
"persona",
ai_persona,
initial_attributes,
ai_persona_logger_fields,
entity_details,
)
end
def log_ai_persona_deletion(persona_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
persona_details[:subject] = persona_details[:name]
logger.log_deletion("persona", persona_details)
end
end
end
end
@@ -10,6 +10,10 @@ module DiscourseAi
end
def update
initial_settings = AiModerationSetting.spam
initial_custom_instructions = initial_settings&.data&.dig("custom_instructions")
initial_llm_model_id = initial_settings&.llm_model_id
updated_params = {}
if allowed_params.key?(:llm_model_id)
llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id]
@@ -36,6 +40,8 @@ module DiscourseAi
else
AiModerationSetting.create!(updated_params.merge(setting_type: :spam))
end
log_ai_spam_update(initial_llm_model_id, initial_custom_instructions, allowed_params)
end
is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled])
@@ -113,6 +119,30 @@ module DiscourseAi
private
def log_ai_spam_update(initial_llm_model_id, initial_custom_instructions, params)
changes_to_log = {}
if params.key?(:llm_model_id) && initial_llm_model_id.to_s != params[:llm_model_id].to_s
old_model_name =
LlmModel.find_by(id: initial_llm_model_id)&.display_name || initial_llm_model_id
new_model_name =
LlmModel.find_by(id: params[:llm_model_id])&.display_name || params[:llm_model_id]
changes_to_log[:llm_model_id] = "#{old_model_name} → #{new_model_name}"
end
if params.key?(:custom_instructions) &&
initial_custom_instructions != params[:custom_instructions]
changes_to_log[:custom_instructions] = params[:custom_instructions]
end
if changes_to_log.present?
changes_to_log[:subject] = I18n.t("discourse_ai.spam_detection.logging_subject")
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_custom("update_ai_spam_settings", changes_to_log)
end
end
def allowed_params
params.permit(:is_enabled, :llm_model_id, :custom_instructions)
end
@@ -25,6 +25,7 @@ module DiscourseAi
if ai_tool.save
RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids)
log_ai_tool_creation(ai_tool)
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
else
render_json_error ai_tool
@@ -32,8 +33,11 @@ module DiscourseAi
end
def update
initial_attributes = @ai_tool.attributes.dup
if @ai_tool.update(ai_tool_params)
RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids)
log_ai_tool_update(@ai_tool, initial_attributes)
render_serialized(@ai_tool, AiCustomToolSerializer)
else
render_json_error @ai_tool
@@ -41,7 +45,15 @@ module DiscourseAi
end
def destroy
tool_logger_details = {
tool_id: @ai_tool.id,
name: @ai_tool.name,
tool_name: @ai_tool.tool_name,
subject: @ai_tool.name,
}
if @ai_tool.destroy
log_ai_tool_deletion(tool_logger_details)
head :no_content
else
render_json_error @ai_tool
@@ -96,6 +108,60 @@ module DiscourseAi
)
.except(:rag_uploads)
end
def ai_tool_logger_fields
{
name: {
},
tool_name: {
},
description: {
},
summary: {
},
enabled: {
},
rag_chunk_tokens: {
},
rag_chunk_overlap_tokens: {
},
rag_llm_model_id: {
},
script: {
type: :large_text,
},
parameters: {
type: :large_text,
},
}
end
def log_ai_tool_creation(ai_tool)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
entity_details[:parameter_count] = ai_tool.parameters.size if ai_tool.parameters.present?
logger.log_creation("tool", ai_tool, ai_tool_logger_fields, entity_details)
end
def log_ai_tool_update(ai_tool, initial_attributes)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
logger.log_update(
"tool",
ai_tool,
initial_attributes,
ai_tool_logger_fields,
entity_details,
)
end
def log_ai_tool_deletion(tool_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_deletion("tool", tool_details)
end
end
end
end
@@ -0,0 +1,124 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
class ArtifactKeyValuesController < ::ApplicationController
requires_plugin DiscourseAi::PLUGIN_NAME
before_action :ensure_logged_in, only: %i[set destroy]
before_action :find_artifact
PER_PAGE_MAX = 100
def index
page = index_params[:page].to_i
page = 1 if page < 1
per_page = index_params[:per_page].to_i
per_page = PER_PAGE_MAX if per_page < 1 || per_page > PER_PAGE_MAX
query = build_index_query
total_count = query.count
key_values =
query
.includes(:user)
.order(:user_id, :key, :created_at)
.offset((page - 1) * per_page)
.limit(per_page + 1)
has_more = key_values.length > per_page
key_values = key_values.first(per_page) if has_more
render json: {
key_values:
ActiveModel::ArraySerializer.new(
key_values,
each_serializer: AiArtifactKeyValueSerializer,
keys_only: params[:keys_only] == "true",
).as_json,
has_more: has_more,
total_count: total_count,
users:
key_values
.map { |kv| kv.user }
.uniq
.map { |u| BasicUserSerializer.new(u, root: nil).as_json },
}
end
def destroy
if params[:key].blank?
render json: { error: "Key parameter is required" }, status: :bad_request
return
end
key_value = @artifact.key_values.find_by(user_id: current_user.id, key: params[:key])
if key_value.nil?
render json: { error: "Key not found" }, status: :not_found
elsif key_value.destroy
head :ok
else
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
end
end
def set
key_value =
@artifact.key_values.find_or_initialize_by(
user: current_user,
key: key_value_params[:key],
)
key_value.assign_attributes(key_value_params.except(:key))
if key_value.save
render json: AiArtifactKeyValueSerializer.new(key_value).as_json
else
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
end
end
private
def key_value_params
params.permit(:key, :value, :public)
end
def index_params
@index_params ||= params.permit(:page, :per_page, :key, :keys_only, :all_users)
end
def build_index_query
query = @artifact.key_values
query =
if current_user&.admin?
query
elsif current_user
query.where("user_id = ? OR public = true", current_user.id)
else
query.where(public: true)
end
query = query.where("key = ?", index_params[:key]) if index_params[:key].present?
if index_params[:all_users].to_s != "true"
if current_user
query = query.where(user_id: current_user.id)
else
query = query.where("1 = 0")
end
end
query
end
def find_artifact
@artifact = AiArtifact.find_by(id: params[:artifact_id])
raise Discourse::NotFound if !@artifact
raise Discourse::NotFound if [email protected]? && guardian.anonymous?
raise Discourse::NotFound if [email protected]? && !guardian.can_see?(@artifact.post)
end
end
end
end
File diff suppressed because it is too large Load Diff
@@ -25,25 +25,24 @@ module DiscourseAi
input = get_text_param!
force_default_locale = params[:force_default_locale] || false
prompt = CompletionPrompt.find_by(id: params[:mode])
raise Discourse::InvalidParameters.new(:mode) if params[:mode].blank?
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
prompt.custom_instruction = params[:custom_prompt]
end
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
if params[:mode] == DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST
return suggest_thumbnails(input)
end
hijack do
render json:
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
prompt,
params[:mode],
input,
current_user,
force_default_locale: force_default_locale,
custom_prompt: params[:custom_prompt],
),
status: 200
end
@@ -60,13 +59,10 @@ module DiscourseAi
input = get_text_param!
end
prompt = CompletionPrompt.enabled_by_name("generate_titles")
raise Discourse::InvalidParameters.new(:mode) if !prompt
hijack do
render json:
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
prompt,
DiscourseAi::AiHelper::Assistant::GENERATE_TITLES,
input,
current_user,
),
@@ -115,12 +111,12 @@ module DiscourseAi
location = params[:location]
raise Discourse::InvalidParameters.new(:location) if !location
prompt = CompletionPrompt.find_by(id: params[:mode])
raise Discourse::InvalidParameters.new(:mode) if params[:mode].blank?
if params[:mode] == DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST
return suggest_thumbnails(input)
end
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end
@@ -133,7 +129,7 @@ module DiscourseAi
:stream_composer_helper,
user_id: current_user.id,
text: text,
prompt: prompt.name,
prompt: params[:mode],
custom_prompt: params[:custom_prompt],
force_default_locale: params[:force_default_locale] || false,
client_id: params[:client_id],
@@ -149,7 +145,7 @@ module DiscourseAi
post_id: post.id,
user_id: current_user.id,
text: text,
prompt: prompt.name,
prompt: params[:mode],
custom_prompt: params[:custom_prompt],
client_id: params[:client_id],
)
+17 -9
View File
@@ -12,20 +12,27 @@ module Jobs
post = Post.find_by(id: args[:post_id])
return if post.blank? || post.raw.blank? || post.deleted_at.present? || post.user_id <= 0
topic = post.topic
return if topic.blank?
if SiteSetting.ai_translation_backfill_limit_to_public_content
topic = post.topic
if topic.blank? || topic.category&.read_restricted? ||
topic.archetype == Archetype.private_message
return if topic.category&.read_restricted? || topic.archetype == Archetype.private_message
else
if topic.archetype == Archetype.private_message &&
!TopicAllowedGroup.exists?(topic_id: topic.id)
return
end
end
begin
detected_locale = DiscourseAi::Translation::PostLocaleDetector.detect_locale(post)
rescue FinalDestination::SSRFDetector::LookupFailedError
# this job is non-critical
# the backfill job will handle failures
return
# the user may fill locale in manually
if (detected_locale = post.locale).blank?
begin
detected_locale = DiscourseAi::Translation::PostLocaleDetector.detect_locale(post)
rescue FinalDestination::SSRFDetector::LookupFailedError
# this job is non-critical
# the backfill job will handle failures
return
end
end
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
@@ -33,6 +40,7 @@ module Jobs
locales.each do |locale|
next if locale == detected_locale
next if post.post_localizations.exists?(locale:)
begin
DiscourseAi::Translation::PostLocalizer.localize(post, locale)
+15 -7
View File
@@ -15,15 +15,22 @@ module Jobs
end
if SiteSetting.ai_translation_backfill_limit_to_public_content
return if topic.category&.read_restricted?
return if topic.category&.read_restricted? || topic.archetype == Archetype.private_message
else
if topic.archetype == Archetype.private_message &&
!TopicAllowedGroup.exists?(topic_id: topic.id)
return
end
end
begin
detected_locale = DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic)
rescue FinalDestination::SSRFDetector::LookupFailedError
# this job is non-critical
# the backfill job will handle failures
return
if (detected_locale = topic.locale).blank?
begin
detected_locale = DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic)
rescue FinalDestination::SSRFDetector::LookupFailedError
# this job is non-critical
# the backfill job will handle failures
return
end
end
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
@@ -31,6 +38,7 @@ module Jobs
locales.each do |locale|
next if locale == detected_locale
next if topic.topic_localizations.exists?(locale:)
begin
DiscourseAi::Translation::TopicLocalizer.localize(topic, locale)
+9 -4
View File
@@ -17,7 +17,8 @@ module Jobs
cat_id = args[:from_category_id] || Category.order(:id).first&.id
last_id = nil
categories = Category.where("id >= ?", cat_id).order(:id).limit(BATCH_SIZE)
categories =
Category.where("id >= ? AND locale IS NOT NULL", cat_id).order(:id).limit(BATCH_SIZE)
return if categories.empty?
categories.each do |category|
@@ -26,9 +27,13 @@ module Jobs
next
end
CategoryLocalization.transaction do
locales.each do |locale|
next if CategoryLocalization.exists?(category_id: category.id, locale: locale)
locales.each do |locale|
localization = category.category_localizations.find_by(locale:)
if locale == category.locale && localization
localization.destroy
else
next if locale == category.locale
begin
DiscourseAi::Translation::CategoryLocalizer.localize(category, locale)
rescue FinalDestination::SSRFDetector::LookupFailedError
+12 -2
View File
@@ -29,12 +29,22 @@ module Jobs
.where.not(locale: locale)
.where("pl.id IS NULL")
posts = posts.joins(:topic)
if SiteSetting.ai_translation_backfill_limit_to_public_content
# exclude all PMs
# and only include posts from public categories
posts =
posts
.joins(:topic)
.where(topics: { category_id: Category.where(read_restricted: false).select(:id) })
.where.not(topics: { archetype: Archetype.private_message })
.where(topics: { category_id: Category.where(read_restricted: false).select(:id) })
else
# all regular topics, and group PMs
posts =
posts.where(
"topics.archetype != ? OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)",
Archetype.private_message,
)
end
if SiteSetting.ai_translation_backfill_max_age_days > 0
+13 -1
View File
@@ -29,7 +29,19 @@ module Jobs
.where("tl.id IS NULL")
if SiteSetting.ai_translation_backfill_limit_to_public_content
topics = topics.where(category_id: Category.where(read_restricted: false).select(:id))
# exclude all PMs
# and only include posts from public categories
topics =
topics
.where.not(archetype: Archetype.private_message)
.where(category_id: Category.where(read_restricted: false).select(:id))
else
# all regular topics, and group PMs
topics =
topics.where(
"topics.archetype != ? OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)",
Archetype.private_message,
)
end
if SiteSetting.ai_translation_backfill_max_age_days > 0
+3 -6
View File
@@ -10,19 +10,16 @@ module Jobs
return unless args[:text]
return unless args[:client_id]
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
prompt.custom_instruction = args[:custom_prompt]
end
helper_mode = args[:prompt]
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
prompt,
helper_mode,
args[:text],
user,
"/discourse-ai/ai-helper/stream_composer_suggestion",
force_default_locale: args[:force_default_locale],
client_id: args[:client_id],
custom_prompt: args[:custom_prompt],
)
end
end
+7 -10
View File
@@ -14,16 +14,12 @@ module Jobs
return unless user.guardian.can_see?(post)
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
helper_mode = args[:prompt]
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>
if helper_mode == DiscourseAi::AiHelper::Assistant::EXPLAIN
input = <<~TEXT.strip
<term>#{args[:text]}</term>
<context>#{post.raw}</context>
<topic>#{topic.title}</topic>
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
TEXT
@@ -32,10 +28,11 @@ module Jobs
end
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
prompt,
helper_mode,
input,
user,
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}",
custom_prompt: args[:custom_prompt],
)
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
module Jobs
class CategoriesLocaleDetectionBackfill < ::Jobs::Scheduled
every 1.hour
sidekiq_options retry: false
cluster_concurrency 1
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
return if SiteSetting.ai_translation_backfill_rate == 0
categories = Category.where(locale: nil)
if SiteSetting.ai_translation_backfill_limit_to_public_content
categories = categories.where(read_restricted: false)
end
categories = categories.limit(SiteSetting.ai_translation_backfill_rate)
return if categories.empty?
categories.each do |category|
begin
DiscourseAi::Translation::CategoryLocaleDetector.detect_locale(category)
rescue FinalDestination::SSRFDetector::LookupFailedError
rescue => e
DiscourseAi::Translation::VerboseLogger.log(
"Failed to detect category #{category.id}'s locale: #{e.message}",
)
end
end
DiscourseAi::Translation::VerboseLogger.log("Detected #{categories.size} category locales")
end
end
end
@@ -10,6 +10,7 @@ module Jobs
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
limit = SiteSetting.ai_translation_backfill_rate
return if limit == 0
topics = Topic.where(locale: nil, deleted_at: nil).where("topics.user_id > 0")
+19 -2
View File
@@ -2,6 +2,7 @@
class AiArtifact < ActiveRecord::Base
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
has_many :key_values, class_name: "AiArtifactKeyValue", dependent: :destroy
belongs_to :user
belongs_to :post
validates :html, length: { maximum: 65_535 }
@@ -15,13 +16,25 @@ class AiArtifact < ActiveRecord::Base
https://ajax.googleapis.com
https://d3js.org
https://code.jquery.com
https://esm.sh
]
def self.artifact_version_attribute(version)
if version
"data-artifact-version='#{version}'"
else
""
end
end
def self.iframe_for(id, version = nil)
<<~HTML
<div class='ai-artifact'>
<iframe src='#{url(id, version)}' frameborder="0" height="100%" width="100%"></iframe>
<a href='#{url(id, version)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
<div class='ai-artifact-controls'>
<a href='#{url(id, version)}' class='link-artifact' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
<a href class='copy-embed' data-artifact-id="#{id}" #{artifact_version_attribute(version)} data-url="#{url(id, version)}">#{I18n.t("discourse_ai.ai_artifact.copy_embed")}</a>
</div>
</div>
HTML
end
@@ -37,7 +50,11 @@ class AiArtifact < ActiveRecord::Base
def self.share_publicly(id:, post:)
artifact = AiArtifact.find_by(id: id)
artifact.update!(metadata: { public: true }) if artifact&.post&.topic&.id == post.topic.id
if artifact&.post&.topic&.id == post.topic.id
artifact.metadata ||= {}
artifact.metadata[:public] = true
artifact.save!
end
end
def self.unshare_publicly(id:)
+56
View File
@@ -0,0 +1,56 @@
# frozen_string_literal: true
class AiArtifactKeyValue < ActiveRecord::Base
belongs_to :ai_artifact
belongs_to :user
validates :key, presence: true, length: { maximum: 50 }
validates :value,
presence: true,
length: {
maximum: ->(_) { SiteSetting.ai_artifact_kv_value_max_length },
}
attribute :public, :boolean, default: false
validates :ai_artifact, presence: true
validates :user, presence: true
validates :key, uniqueness: { scope: %i[ai_artifact_id user_id] }
validate :validate_max_keys_per_user_per_artifact
private
def validate_max_keys_per_user_per_artifact
return unless ai_artifact_id && user_id
max_keys = SiteSetting.ai_artifact_max_keys_per_user_per_artifact
existing_count = self.class.where(ai_artifact_id: ai_artifact_id, user_id: user_id).count
# Don't count the current record if it's being updated
existing_count -= 1 if persisted?
if existing_count >= max_keys
errors.add(
:base,
I18n.t("discourse_ai.ai_artifact.errors.max_keys_exceeded", count: max_keys),
)
end
end
end
# == Schema Information
#
# Table name: ai_artifact_key_values
#
# id :bigint not null, primary key
# ai_artifact_id :bigint not null
# user_id :integer not null
# key :string(50) not null
# value :string(20000) not null
# public :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_ai_artifact_kv_unique (ai_artifact_id,user_id,key) UNIQUE
#
+4
View File
@@ -316,6 +316,10 @@ class AiPersona < ActiveRecord::Base
end
end
def features
DiscourseAi::Configuration::Feature.find_features_using(persona_id: id)
end
private
def chat_preconditions
+5
View File
@@ -1,6 +1,11 @@
# frozen_string_literal: true
class CompletionPrompt < ActiveRecord::Base
#
# DEPRECATED.
# TODO(roman): Remove after 06-17-25
#
TRANSLATE = -301
GENERATE_TITLES = -307
PROOFREAD = -303
+1
View File
@@ -52,6 +52,7 @@ class LlmModel < ActiveRecord::Base
disable_temperature: :checkbox,
disable_top_p: :checkbox,
disable_streaming: :checkbox,
enable_responses_api: :checkbox,
reasoning_effort: {
type: :enum,
values: %w[default low medium high],
+4 -2
View File
@@ -37,7 +37,9 @@ class SharedAiConversation < ActiveRecord::Base
maybe_topic = conversation.target
if maybe_topic.is_a?(Topic)
AiArtifact.where(post: maybe_topic.posts).update_all(metadata: { public: false })
AiArtifact.where(post: maybe_topic.posts).update_all(
"metadata = jsonb_set(COALESCE(metadata, '{}'), '{public}', 'false')",
)
end
::Jobs.enqueue(
@@ -176,7 +178,7 @@ class SharedAiConversation < ActiveRecord::Base
def self.cook_artifacts(post)
html = post.cooked
return html if !%w[lax strict].include?(SiteSetting.ai_artifact_security)
return html if !%w[lax hybrid strict].include?(SiteSetting.ai_artifact_security)
doc = Nokogiri::HTML5.fragment(html)
doc
@@ -0,0 +1,9 @@
# frozen_string_literal: true
class AiArtifactKeyValueSerializer < ApplicationSerializer
attributes :id, :key, :value, :public, :user_id, :created_at, :updated_at
def include_value?
!options[:keys_only]
end
end
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class AiFeaturesPersonaSerializer < ApplicationSerializer
attributes :id, :name, :system_prompt, :allowed_groups, :enabled
attributes :id, :name, :allowed_groups
def allowed_groups
Group

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