├── test
└── javascripts
│ ├── .gitkeep
│ └── integration
│ └── components
│ └── ai-indicator-wave-test.gjs
├── .npmrc
├── .prettierrc.cjs
├── .template-lintrc.cjs
├── .streerc
├── stylelint.config.mjs
├── admin
└── assets
│ └── javascripts
│ └── discourse
│ ├── templates
│ └── admin-plugins
│ │ └── show
│ │ ├── discourse-ai-spam.hbs
│ │ ├── discourse-ai-usage.hbs
│ │ ├── discourse-ai-llms
│ │ ├── index.hbs
│ │ ├── edit.hbs
│ │ └── new.hbs
│ │ ├── discourse-ai-features
│ │ ├── index.hbs
│ │ └── edit.gjs
│ │ ├── discourse-ai-tools
│ │ ├── index.hbs
│ │ ├── edit.hbs
│ │ └── new.hbs
│ │ ├── discourse-ai-personas
│ │ ├── index.hbs
│ │ ├── edit.hbs
│ │ └── new.hbs
│ │ └── discourse-ai-embeddings
│ │ ├── index.hbs
│ │ ├── edit.hbs
│ │ └── new.hbs
│ └── routes
│ ├── admin-plugins-show-discourse-ai-llms.js
│ ├── admin-plugins-show-discourse-ai-personas.js
│ ├── admin-plugins-show-discourse-ai-embeddings.js
│ ├── admin-plugins-show-discourse-ai-tools.js
│ ├── admin-plugins-show-discourse-ai-features.js
│ ├── admin-plugins-show-discourse-ai-spam.js
│ ├── admin-plugins-show-discourse-ai-usage.js
│ ├── admin-plugins-show-discourse-ai-embeddings-new.js
│ ├── admin-plugins-show-discourse-ai-personas-edit.js
│ ├── admin-plugins-show-discourse-ai-llms-new.js
│ ├── admin-plugins-show-discourse-ai-embeddings-edit.js
│ ├── admin-plugins-show-discourse-ai-tools-edit.js
│ └── admin-plugins-show-discourse-ai-tools-new.js
├── spec
├── fixtures
│ ├── images
│ │ ├── 1x1.gif
│ │ ├── 1x1.jpg
│ │ ├── 1x1.webp
│ │ ├── 100x100.jpg
│ │ └── An image of discobot in action.png
│ └── rag
│ │ └── 2-page.pdf
├── fabricators
│ ├── inferred_concept_fabricator.rb
│ ├── llm_quota_fabricator.rb
│ ├── ai_persona_fabricator.rb
│ ├── rag_document_fragment_fabricator.rb
│ ├── llm_quota_usage_fabricator.rb
│ ├── ai_tool_fabricator.rb
│ ├── ai_summary_fabricator.rb
│ ├── classification_result_fabricator.rb
│ └── ai_artifact_fabricator.rb
├── system
│ ├── core_features_spec.rb
│ ├── page_objects
│ │ ├── pages
│ │ │ ├── discourse_ai
│ │ │ │ └── header.rb
│ │ │ └── user_preferences_ai.rb
│ │ ├── modals
│ │ │ ├── ai_tool_test_modal.rb
│ │ │ └── diff_modal.rb
│ │ └── components
│ │ │ └── ai_summary_box.rb
│ └── admin_dashboard_spec.rb
├── lib
│ └── personas
│ │ ├── researcher_spec.rb
│ │ ├── sql_helper_spec.rb
│ │ ├── settings_explorer_spec.rb
│ │ └── tools
│ │ ├── list_categories_spec.rb
│ │ ├── time_spec.rb
│ │ ├── list_tags_spec.rb
│ │ └── db_schema_spec.rb
├── models
│ └── llm_model_spec.rb
├── support
│ └── stable_diffusion_stubs.rb
├── configuration
│ └── llm_validator_spec.rb
├── serializers
│ └── ai_chat_channel_serializer_spec.rb
└── reports
│ └── sentiment_analysis_spec.rb
├── eslint.config.mjs
├── db
├── fixtures
│ └── ai_bot
│ │ └── 602_bot_users.rb
├── migrate
│ ├── 20241031145203_track_ai_summary_origin.rb
│ ├── 20240624135356_llm_model_custom_params.rb
│ ├── 20250508154953_add_examples_to_personas.rb
│ ├── 20250211021037_add_error_to_ai_spam_log.rb
│ ├── 20250122003035_add_duration_to_ai_api_log.rb
│ ├── 20230320191928_drop_completion_prompt_value.rb
│ ├── 20230320185619_multi_message_completion_prompts.rb
│ ├── 20250411121705_add_response_format_json_to_personass.rb
│ ├── 20250417194503_add_max_output_tokens_to_llm_model.rb
│ ├── 20240719143453_llm_model_vision_enabled.rb
│ ├── 20240909180908_add_ai_summary_type_column.rb
│ ├── 20241023033955_add_feature_context_to_ai_api_log.rb
│ ├── 20240807150605_add_default_to_provider_params.rb
│ ├── 20240503034946_add_allow_chat_to_ai_persona.rb
│ ├── 20240527054218_add_language_model_to_ai_audit_logs.rb
│ ├── 20240514001334_add_feature_name_to_ai_api_audit_log.rb
│ ├── 20250210024600_add_rag_llm_model.rb
│ ├── 20240514171609_add_endpoint_data_to_llm_model.rb
│ ├── 20240213051213_add_limits_to_ai_persona.rb
│ ├── 20241009230724_add_forced_tool_count_to_ai_personas.rb
│ ├── 20240424220101_add_auto_image_caption_to_user_options.rb
│ ├── 20250619105705_add_persona_to_ai_moderation_settings.rb
│ ├── 20240429065155_add_consolidated_question_llm_to_ai_persona.rb
│ ├── 20250310172527_add_ai_search_discoveries_to_user_options.rb
│ ├── 20231031050538_add_topic_id_post_id_to_ai_audit_log.rb
│ ├── 20240202010752_add_temperature_top_p_to_ai_personas.rb
│ ├── 20240404000838_add_metadata_to_rag_document_frament.rb
│ ├── 20240528132059_add_companion_user_to_llm_model.rb
│ ├── 20240104013944_add_params_to_completion_prompt.rb
│ ├── 20231128151234_recreate_generate_titles_prompt.rb
│ ├── 20240609232736_drop_commands_from_ai_personas.rb
│ ├── 20250722082515_add_index_to_ai_topics_embeddings.rb
│ ├── 20231123224203_switch_to_generic_completion_prompts.rb
│ ├── 20240913054440_add_rag_columns_to_ai_tools.rb
│ ├── 20250416215039_add_cost_metrics_to_llm_model.rb
│ ├── 20231117050928_add_system_and_priority_to_ai_personas.rb
│ ├── 20250424035234_remove_old_settings.rb
│ ├── 20250508182047_create_inferred_concepts_table.rb
│ ├── 20241028034232_add_unique_ai_stream_conversation_user_id_index.rb
│ ├── 20231120033747_remove_site_settings.rb
│ ├── 20241128010221_add_cached_tokens_to_ai_api_audit_log.rb
│ ├── 20230519003106_post_custom_prompts.rb
│ ├── 20240322035907_add_images_to_ai_personas.rb
│ ├── 20250620073222_specify_rate_frequency_in_backfill_setting.rb
│ ├── 20240912052713_add_target_to_rag_document_fragment.rb
│ ├── 20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb
│ ├── 20241031180044_set_origin_for_existing_ai_summaries.rb
│ ├── 20240503042558_add_chat_message_custom_prompt.rb
│ ├── 20250407125756_set_correct_default_for_short_summarizer_persona.rb
│ ├── 20240504222307_create_llm_model_table.rb
│ ├── 20240309034752_create_rag_document_fragment_table.rb
│ ├── 20250501002657_renamed_experimental_ai_bot_setting.rb
│ ├── 20230831033812_rename_ai_helper_add_ai_pm_to_header_setting.rb
│ ├── 20241025135522_alter_ai_ids_to_bigint.rb
│ ├── 20241126033812_rename_ai_gist_batch_setting.rb
│ ├── 20230322142028_make_dropped_value_column_nullable.rb
│ ├── 20230314184514_migrate_discourse_ai_reviewables.rb
│ ├── 20230424055354_create_ai_api_audit_logs.rb
│ ├── 20250115173456_add_highest_target_number_to_ai_summary.rb
│ ├── 20230320122645_delete_duplicated_seeded_prompts.rb
│ ├── 20240606151348_create_ai_summaries_table.rb
│ ├── 20250715165701_update_open_ai_embeddings_tokenizer.rb
│ ├── 20230307125342_created_model_accuracy_table.rb
│ ├── 20241206051225_add_ai_spam_logs.rb
│ ├── 20230316160714_create_completion_prompt_table.rb
│ ├── 20231003155701_create_bge_topic_embeddings_table.rb
│ ├── 20240611170904_upgrade_pgvector_070.rb
│ ├── 20241206030229_add_ai_moderation_settings.rb
│ ├── 20231227223301_create_gemini_topic_embeddings_table.rb
│ ├── 20241104053017_add_ai_artifacts.rb
│ ├── 20230727170222_create_multilingual_topic_embeddings_table.rb
│ ├── 20240708193243_fix_vllm_model_name.rb
│ ├── 20241130003808_add_artifact_versions.rb
│ ├── 20250509000001_create_inferred_concept_posts.rb
│ ├── 20240409035951_add_rag_params_to_ai_persona.rb
│ ├── 20250508183456_create_inferred_concept_topics.rb
│ ├── 20250717075002_set_translation_backfill_max_age.rb
│ ├── 20240618080148_create_ai_tools.rb
│ ├── 20241125132452_unique_ai_summaries.rb
│ ├── 20250429060311_move_dall_e_url.rb
│ ├── 20240606152117_copy_summary_sections_to_ai_summaries.rb
│ ├── 20241020010245_add_tool_name_to_ai_tools.rb
│ ├── 20250607071239_create_ai_artifacts_key_values.rb
│ ├── 20230224165056_create_classification_results_table.rb
│ ├── 20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb
│ ├── 20250121162520_configurable_embeddings_prefixes.rb
│ ├── 20231109011155_create_ai_personas.rb
│ ├── 20240609061418_tool_details_and_command_removal.rb
│ ├── 20241217164540_create_embedding_definitions.rb
│ ├── 20240704020102_reset_identity_on_ai_summary.rb
│ ├── 20250210032345_migrate_persona_to_llm_model_id.rb
│ ├── 20250122131007_matryoshka_dimensions_support.rb
│ ├── 20230406135943_add_provider_to_completion_prompts.rb
│ ├── 20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb
│ ├── 20241023041242_add_unique_constraint_to_ai_tools.rb
│ ├── 20250114160500_backfill_rag_embeddings.rb
│ ├── 20250721192553_enable_ai_if_already_installed.rb
│ ├── 20230710171141_enable_pg_vector_extension.rb
│ └── 20241008054440_create_binary_indexes_for_embeddings.rb
└── post_migrate
│ ├── 20241014041242_ai_persona_post_migrate_drop_cols.rb
│ ├── 20250113171444_drop_old_embedding_tables.rb
│ ├── 20250210032351_post_migrate_persona_to_llm_model_id.rb
│ ├── 20250115181147_drop_ai_summaries_content_range.rb
│ ├── 20240809162837_rename_ai_helper_enabled_setting.rb
│ ├── 20240809163303_rename_ai_helper_allowed_groups_setting.rb
│ ├── 20241206115958_rebake_shared_ai_conversation_oneboxes.rb
│ ├── 20240912055831_drop_persona_id_from_rag_document_fragments.rb
│ └── 20250404045050_migrate_users_to_email_group.rb
├── Gemfile
├── assets
├── javascripts
│ ├── discourse
│ │ ├── preferences-ai-route-map.js
│ │ ├── discourse-ai-bot-dashboard-route-map.js
│ │ ├── components
│ │ │ ├── ai-bot-sidebar-empty-state.gjs
│ │ │ ├── ai-indicator-wave.gjs
│ │ │ ├── ai-tool-selector.gjs
│ │ │ ├── reviewable-ai-chat-message.js
│ │ │ ├── post
│ │ │ │ └── ai-persona-flair.gjs
│ │ │ ├── ai-llm-editor.gjs
│ │ │ ├── ai-llm-selector.gjs
│ │ │ ├── model-accuracies.hbs
│ │ │ ├── ai-summary-skeleton.gjs
│ │ │ ├── ai-helper-loading.gjs
│ │ │ ├── reviewable-ai-post.hbs
│ │ │ └── post-menu
│ │ │ │ └── ai-debug-button.gjs
│ │ ├── discourse-ai-shared-conversation-show-route-map.js
│ │ ├── services
│ │ │ ├── quick-search.js
│ │ │ └── gists.js
│ │ ├── admin
│ │ │ ├── models
│ │ │ │ └── ai-feature.js
│ │ │ └── adapters
│ │ │ │ ├── ai-llm.js
│ │ │ │ ├── ai-persona.js
│ │ │ │ ├── ai-tool.js
│ │ │ │ ├── ai-embedding.js
│ │ │ │ └── ai-feature.js
│ │ ├── connectors
│ │ │ ├── after-d-editor
│ │ │ │ └── composer-open.hbs
│ │ │ ├── after-search-result-entry
│ │ │ │ └── search-result-decoration.gjs
│ │ │ ├── before-create-topic-button
│ │ │ │ └── topic-list-gist-toggle.gjs
│ │ │ ├── topic-list-before-category
│ │ │ │ └── ai-topic-gist-placement.gjs
│ │ │ ├── search-menu-before-advanced-search
│ │ │ │ └── ai-quick-search-loader.gjs
│ │ │ ├── admin-dashboard-tabs-after
│ │ │ │ └── admin-sentiment-dashbboard.gjs
│ │ │ ├── split-new-topic-title-after
│ │ │ │ └── ai-title-suggestion.gjs
│ │ │ ├── after-composer-tag-input
│ │ │ │ └── ai-tag-suggestion.gjs
│ │ │ ├── full-page-search-below-search-header
│ │ │ │ └── ai-full-page-search.gjs
│ │ │ ├── edit-topic-tags__after
│ │ │ │ └── ai-tag-suggestion.gjs
│ │ │ ├── edit-topic-title__after
│ │ │ │ └── ai-title-suggestion.gjs
│ │ │ ├── user-preferences-nav
│ │ │ │ └── ai-preferences.gjs
│ │ │ ├── edit-topic-category__after
│ │ │ │ └── ai-category-suggestion.gjs
│ │ │ ├── composer-after-save-or-cancel
│ │ │ │ └── ai-image-caption-loader.gjs
│ │ │ ├── after-composer-title-input
│ │ │ │ └── ai-title-suggestion.gjs
│ │ │ ├── split-new-topic-category-after
│ │ │ │ └── ai-category-suggestion.gjs
│ │ │ ├── split-new-topic-tag-after
│ │ │ │ └── ai-tag-suggestion.gjs
│ │ │ └── after-composer-category-input
│ │ │ │ └── ai-category-suggestion.gjs
│ │ ├── admin-discourse-ai-route-map.js
│ │ ├── templates
│ │ │ └── discourse-ai-bot-conversations.gjs
│ │ ├── lib
│ │ │ └── ai-streamer
│ │ │ │ └── updaters
│ │ │ │ └── stream-updater.js
│ │ └── routes
│ │ │ ├── preferences-ai.js
│ │ │ └── discourse-ai-shared-conversation-show.js
│ ├── initializers
│ │ ├── ai-semantic-search.js
│ │ ├── ai-search-discoveries.js
│ │ ├── ai-gist-topic-list-class.js
│ │ ├── translation.js
│ │ └── ai-sentiment-admin-nav.js
│ └── lib
│ │ └── discourse-markdown
│ │ └── ai-tags.js
└── stylesheets
│ ├── modules
│ ├── ai-bot
│ │ └── mobile
│ │ │ └── ai-persona.scss
│ ├── ai-helper
│ │ └── desktop
│ │ │ └── ai-helper-fk-modals.scss
│ ├── embeddings
│ │ └── common
│ │ │ └── semantic-related-topics.scss
│ └── summarization
│ │ └── desktop
│ │ └── ai-summary.scss
│ └── common
│ └── ai-user-settings.scss
├── .rubocop.yml
├── app
├── serializers
│ ├── basic_llm_model_serializer.rb
│ ├── inferred_concept_serializer.rb
│ ├── reviewable_ai_post_serializer.rb
│ ├── llm_quota_serializer.rb
│ ├── ai_artifact_key_value_serializer.rb
│ ├── ai_features_persona_serializer.rb
│ ├── ai_topic_summary_serializer.rb
│ ├── reviewable_ai_chat_message_serializer.rb
│ ├── ai_api_audit_log_serializer.rb
│ ├── ai_custom_tool_list_serializer.rb
│ ├── ai_chat_channel_serializer.rb
│ ├── ai_custom_tool_serializer.rb
│ ├── ai_sentiment_post_serializer.rb
│ ├── ai_embedding_definition_serializer.rb
│ ├── ai_tool_serializer.rb
│ └── ai_inferred_concept_post_serializer.rb
├── mailers
│ └── ai_report_mailer.rb
├── jobs
│ ├── scheduled
│ │ ├── remove_orphaned_embeddings.rb
│ │ ├── category_localization_backfill.rb
│ │ ├── topic_localization_backfill.rb
│ │ └── post_localization_backfill.rb
│ └── regular
│ │ ├── ai_spam_scan.rb
│ │ ├── manage_embedding_def_search_index.rb
│ │ ├── post_sentiment_analysis.rb
│ │ ├── stream_discord_reply.rb
│ │ ├── generate_chat_thread_title.rb
│ │ ├── fast_track_topic_gist.rb
│ │ ├── stream_composer_helper.rb
│ │ ├── generate_embeddings.rb
│ │ └── create_ai_chat_reply.rb
├── controllers
│ └── discourse_ai
│ │ └── admin
│ │ ├── dashboard_controller.rb
│ │ └── ai_usage_controller.rb
└── models
│ ├── post_custom_prompt.rb
│ ├── chat_message_custom_prompt.rb
│ ├── inferred_concept.rb
│ ├── inferred_concept_post.rb
│ ├── inferred_concept_topic.rb
│ ├── classification_result.rb
│ └── ai_spam_log.rb
├── lib
├── engine.rb
├── sentiment
│ ├── constants.rb
│ └── emotions.rb
├── ai_bot
│ └── site_settings_extension.rb
├── database
│ └── connection.rb
├── translation
│ ├── post_raw_translator.rb
│ ├── short_text_translator.rb
│ ├── topic_title_translator.rb
│ ├── verbose_logger.rb
│ ├── topic_locale_detector.rb
│ ├── post_locale_detector.rb
│ ├── category_locale_detector.rb
│ └── entry_point.rb
├── embeddings.rb
├── personas
│ ├── creative.rb
│ ├── tools
│ │ └── option.rb
│ ├── settings_explorer.rb
│ ├── image_captioner.rb
│ └── custom_prompt.rb
├── completions
│ ├── endpoints
│ │ └── mistral.rb
│ └── dialects
│ │ └── fake.rb
├── tasks
│ └── modules
│ │ └── sentiment
│ │ └── backfill.rake
├── configuration
│ ├── persona_enumerator.rb
│ ├── embedding_defs_validator.rb
│ ├── embedding_defs_enumerator.rb
│ ├── embeddings_module_validator.rb
│ ├── spam_detection_validator.rb
│ └── llm_vision_enumerator.rb
├── topic_extensions.rb
├── translation.rb
├── automation
│ ├── llm_tool_triage.rb
│ └── llm_persona_triage.rb
├── post_extensions.rb
├── inference
│ └── discourse_reranker.rb
└── multisite_hash.rb
├── about.json
├── .gitignore
├── .github
└── workflows
│ └── discourse-plugin.yml
├── translator.yml
├── config
└── locales
│ ├── server.en_GB.yml
│ ├── server.sr.yml
│ ├── server.sq.yml
│ ├── server.sl.yml
│ ├── server.sw.yml
│ ├── server.lv.yml
│ ├── server.th.yml
│ ├── server.bs_BA.yml
│ ├── server.nb_NO.yml
│ ├── server.ro.yml
│ ├── server.bg.yml
│ ├── server.el.yml
│ ├── server.pt.yml
│ ├── server.vi.yml
│ ├── server.hr.yml
│ ├── server.be.yml
│ ├── client.en_GB.yml
│ ├── server.et.yml
│ ├── server.ca.yml
│ ├── server.gl.yml
│ ├── server.da.yml
│ ├── server.ko.yml
│ ├── server.zh_TW.yml
│ ├── server.hy.yml
│ ├── server.te.yml
│ ├── server.ur.yml
│ ├── server.hu.yml
│ ├── server.sv.yml
│ ├── server.ug.yml
│ └── server.sk.yml
├── package.json
├── .prettierignore
├── evals
└── run
└── .discourse-compatibility
/test/javascripts/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict = true
2 | auto-install-peers = false
3 |
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require("@discourse/lint-configs/prettier");
2 |
--------------------------------------------------------------------------------
/.template-lintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require("@discourse/lint-configs/template-lint");
2 |
--------------------------------------------------------------------------------
/.streerc:
--------------------------------------------------------------------------------
1 | --print-width=100
2 | --plugins=plugin/trailing_comma,plugin/disable_auto_ternary
3 |
--------------------------------------------------------------------------------
/stylelint.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | extends: ["@discourse/lint-configs/stylelint"],
3 | };
4 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-spam.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/fixtures/images/1x1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discourse/discourse-ai/HEAD/spec/fixtures/images/1x1.gif
--------------------------------------------------------------------------------
/spec/fixtures/images/1x1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discourse/discourse-ai/HEAD/spec/fixtures/images/1x1.jpg
--------------------------------------------------------------------------------
/spec/fixtures/images/1x1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discourse/discourse-ai/HEAD/spec/fixtures/images/1x1.webp
--------------------------------------------------------------------------------
/spec/fixtures/rag/2-page.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discourse/discourse-ai/HEAD/spec/fixtures/rag/2-page.pdf
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-usage.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/fixtures/images/100x100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discourse/discourse-ai/HEAD/spec/fixtures/images/100x100.jpg
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/index.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/index.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/index.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/index.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import DiscourseRecommended from "@discourse/lint-configs/eslint";
2 |
3 | export default [...DiscourseRecommended];
4 |
--------------------------------------------------------------------------------
/db/fixtures/ai_bot/602_bot_users.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots
4 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/index.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/edit.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/fabricators/inferred_concept_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | Fabricator(:inferred_concept) { name { sequence(:name) { |i| "concept_#{i}" } } }
3 |
--------------------------------------------------------------------------------
/spec/fixtures/images/An image of discobot in action.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discourse/discourse-ai/HEAD/spec/fixtures/images/An image of discobot in action.png
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | group :development do
6 | gem "rubocop-discourse"
7 | gem "syntax_tree"
8 | end
9 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/preferences-ai-route-map.js:
--------------------------------------------------------------------------------
1 | export default {
2 | resource: "user.preferences",
3 |
4 | map() {
5 | this.route("ai");
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | inherit_gem:
2 | rubocop-discourse: stree-compat.yml
3 | RSpec/NamedSubject:
4 | Enabled: false
5 |
6 | Style/GlobalVars:
7 | AllowedVariables: [$prometheus_client]
8 |
--------------------------------------------------------------------------------
/app/serializers/basic_llm_model_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class BasicLlmModelSerializer < ApplicationSerializer
4 | attributes :id, :display_name
5 | end
6 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/edit.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/new.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/stylesheets/modules/ai-bot/mobile/ai-persona.scss:
--------------------------------------------------------------------------------
1 | .ai-persona-editor {
2 | &__system_prompt,
3 | &__description,
4 | .select-kit.multi-select {
5 | width: 100%;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/lib/engine.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::DiscourseAi
4 | class Engine < ::Rails::Engine
5 | engine_name PLUGIN_NAME
6 | isolate_namespace DiscourseAi
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/edit.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/new.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/serializers/inferred_concept_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class InferredConceptSerializer < ApplicationSerializer
4 | attributes :id, :name, :created_at, :updated_at
5 | end
6 |
--------------------------------------------------------------------------------
/about.json:
--------------------------------------------------------------------------------
1 | {
2 | "tests": {
3 | "requiredPlugins": [
4 | "https://github.com/discourse/discourse-prometheus",
5 | "https://github.com/discourse/discourse-solved.git"
6 | ]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/lib/sentiment/constants.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Sentiment
5 | module Constants
6 | SENTIMENT_THRESHOLD = 0.6
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/new.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | this.route("discourse-ai-bot-conversations", {
3 | path: "/discourse-ai/ai-bot/conversations",
4 | });
5 | }
6 |
--------------------------------------------------------------------------------
/spec/system/core_features_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe "Core features", type: :system do
4 | before { enable_current_plugin }
5 |
6 | it_behaves_like "having working core features"
7 | end
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /gems
3 | /auto_generated
4 | .env
5 | evals/log
6 | evals/cases
7 | config/eval-llms.local.yml
8 | # this gets rid of search results from ag, ripgrep, etc
9 | public/ai-share/highlight.min.js
10 |
--------------------------------------------------------------------------------
/db/migrate/20241031145203_track_ai_summary_origin.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class TrackAiSummaryOrigin < ActiveRecord::Migration[7.1]
3 | def change
4 | add_column :ai_summaries, :origin, :integer
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20240624135356_llm_model_custom_params.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class LlmModelCustomParams < ActiveRecord::Migration[7.0]
3 | def change
4 | add_column :llm_models, :provider_params, :jsonb
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20250508154953_add_examples_to_personas.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddExamplesToPersonas < ActiveRecord::Migration[7.2]
4 | def change
5 | add_column :ai_personas, :examples, :jsonb
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fabricators/llm_quota_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Fabricator(:llm_quota) do
4 | group
5 | llm_model
6 | max_tokens { 1000 }
7 | max_usages { 10 }
8 | duration_seconds { 1.day.to_i }
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20250211021037_add_error_to_ai_spam_log.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddErrorToAiSpamLog < ActiveRecord::Migration[7.2]
3 | def change
4 | add_column :ai_spam_logs, :error, :string, limit: 3000
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/spec/fabricators/ai_persona_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | Fabricator(:ai_persona) do
3 | name { sequence(:name) { |i| "persona_#{i}" } }
4 | description "I am a test bot"
5 | system_prompt "You are a test bot"
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20250122003035_add_duration_to_ai_api_log.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddDurationToAiApiLog < ActiveRecord::Migration[7.2]
3 | def change
4 | add_column :ai_api_audit_logs, :duration_msecs, :integer
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/.github/workflows/discourse-plugin.yml:
--------------------------------------------------------------------------------
1 | name: Discourse Plugin
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | ci:
11 | uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1
12 |
--------------------------------------------------------------------------------
/db/migrate/20230320191928_drop_completion_prompt_value.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class DropCompletionPromptValue < ActiveRecord::Migration[7.0]
4 | def change
5 | remove_column :completion_prompts, :value, :text
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/translator.yml:
--------------------------------------------------------------------------------
1 | # Configuration file for discourse-translator-bot
2 |
3 | files:
4 | - source_path: config/locales/client.en.yml
5 | destination_path: client.yml
6 | - source_path: config/locales/server.en.yml
7 | destination_path: server.yml
8 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/ai-bot-sidebar-empty-state.gjs:
--------------------------------------------------------------------------------
1 | import { i18n } from "discourse-i18n";
2 |
3 |
4 |
7 |
8 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/discourse-ai-shared-conversation-show-route-map.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | this.route("discourse-ai-shared-conversation-show", {
3 | path: "/discourse-ai/ai-bot/shared-ai-conversations/:share_key",
4 | });
5 | }
6 |
--------------------------------------------------------------------------------
/assets/stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss:
--------------------------------------------------------------------------------
1 | .fk-d-menu {
2 | .ai-post-helper {
3 | &__suggestion__text,
4 | &__suggestion__buttons {
5 | padding: 0.75em 1rem;
6 | margin: 0;
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/config/locales/server.en_GB.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | en_GB:
8 |
--------------------------------------------------------------------------------
/lib/ai_bot/site_settings_extension.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi::AiBot::SiteSettingsExtension
4 | def self.enable_or_disable_ai_bots
5 | LlmModel.find_each { |llm_model| llm_model.toggle_companion_user }
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/serializers/reviewable_ai_post_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_dependency "reviewable_flagged_post_serializer"
4 |
5 | class ReviewableAiPostSerializer < ReviewableFlaggedPostSerializer
6 | payload_attributes :accuracies
7 | end
8 |
--------------------------------------------------------------------------------
/db/migrate/20230320185619_multi_message_completion_prompts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class MultiMessageCompletionPrompts < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :completion_prompts, :messages, :jsonb
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/db/migrate/20250411121705_add_response_format_json_to_personass.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddResponseFormatJsonToPersonass < ActiveRecord::Migration[7.2]
3 | def change
4 | add_column :ai_personas, :response_format, :jsonb
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20250417194503_add_max_output_tokens_to_llm_model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddMaxOutputTokensToLlmModel < ActiveRecord::Migration[7.2]
4 | def change
5 | add_column :llm_models, :max_output_tokens, :integer
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/mailers/ai_report_mailer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiReportMailer < ActionMailer::Base
4 | include Email::BuildEmailHelper
5 |
6 | def send_report(to_address, opts = {})
7 | build_email(to_address, **opts)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20240719143453_llm_model_vision_enabled.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class LlmModelVisionEnabled < ActiveRecord::Migration[7.1]
3 | def change
4 | add_column :llm_models, :vision_enabled, :boolean, default: false, null: false
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20240909180908_add_ai_summary_type_column.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddAiSummaryTypeColumn < ActiveRecord::Migration[7.1]
3 | def change
4 | add_column :ai_summaries, :summary_type, :integer, default: 0, null: false
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20241023033955_add_feature_context_to_ai_api_log.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | #
3 | class AddFeatureContextToAiApiLog < ActiveRecord::Migration[7.1]
4 | def change
5 | add_column :ai_api_audit_logs, :feature_context, :jsonb
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/services/quick-search.js:
--------------------------------------------------------------------------------
1 | import { tracked } from "@glimmer/tracking";
2 | import Service from "@ember/service";
3 |
4 | export default class QuickSearch extends Service {
5 | @tracked loading = false;
6 | @tracked invalidTerm = false;
7 | }
8 |
--------------------------------------------------------------------------------
/db/migrate/20240807150605_add_default_to_provider_params.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddDefaultToProviderParams < ActiveRecord::Migration[7.1]
3 | def change
4 | change_column_default :llm_models, :provider_params, from: nil, to: {}
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20240503034946_add_allow_chat_to_ai_persona.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddAllowChatToAiPersona < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :ai_personas, :allow_chat, :boolean, default: false, null: false
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/db/migrate/20240527054218_add_language_model_to_ai_audit_logs.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddLanguageModelToAiAuditLogs < ActiveRecord::Migration[7.0]
3 | def change
4 | add_column :ai_api_audit_logs, :language_model, :string, limit: 255
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/admin/models/ai-feature.js:
--------------------------------------------------------------------------------
1 | import RestModel from "discourse/models/rest";
2 |
3 | export default class AiFeature extends RestModel {
4 | createProperties() {
5 | return this.getProperties("id", "module", "global_enabled", "features");
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/assets/stylesheets/common/ai-user-settings.scss:
--------------------------------------------------------------------------------
1 | .user-preferences .ai-user-preferences {
2 | legend {
3 | margin-bottom: 1rem;
4 | }
5 |
6 | .control-group {
7 | margin-bottom: 0;
8 | }
9 |
10 | .save-button {
11 | margin-top: 2rem;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/db/migrate/20240514001334_add_feature_name_to_ai_api_audit_log.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddFeatureNameToAiApiAuditLog < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :ai_api_audit_logs, :feature_name, :string, limit: 255
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/db/migrate/20250210024600_add_rag_llm_model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddRagLlmModel < ActiveRecord::Migration[7.2]
3 | def change
4 | add_column :ai_personas, :rag_llm_model_id, :bigint
5 | add_column :ai_tools, :rag_llm_model_id, :bigint
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/fabricators/rag_document_fragment_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Fabricator(:rag_document_fragment) do
4 | fragment { sequence(:fragment) { |n| "Document fragment #{n}" } }
5 | upload
6 | fragment_number { sequence(:fragment_number) { |n| n + 1 } }
7 | end
8 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class DiscourseAiAiLlmsRoute extends DiscourseRoute {
4 | model() {
5 | return this.store.findAll("ai-llm");
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/db/migrate/20240514171609_add_endpoint_data_to_llm_model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddEndpointDataToLlmModel < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :llm_models, :url, :string
6 | add_column :llm_models, :api_key, :string
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/20240213051213_add_limits_to_ai_persona.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddLimitsToAiPersona < ActiveRecord::Migration[7.0]
4 | def change
5 | change_table :ai_personas do |t|
6 | t.integer :max_context_posts, null: true
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20241009230724_add_forced_tool_count_to_ai_personas.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddForcedToolCountToAiPersonas < ActiveRecord::Migration[7.1]
4 | def change
5 | add_column :ai_personas, :forced_tool_count, :integer, default: -1, null: false
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class DiscourseAiAiPersonasRoute extends DiscourseRoute {
4 | model() {
5 | return this.store.findAll("ai-persona");
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/db/migrate/20240424220101_add_auto_image_caption_to_user_options.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddAutoImageCaptionToUserOptions < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :user_options, :auto_image_caption, :boolean, default: false, null: false
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/db/migrate/20250619105705_add_persona_to_ai_moderation_settings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddPersonaToAiModerationSettings < ActiveRecord::Migration[7.2]
3 | def change
4 | add_column :ai_moderation_settings, :ai_persona_id, :bigint, null: false, default: -31
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AiPersonaPostMigrateDropCols < ActiveRecord::Migration[7.1]
3 | def change
4 | remove_columns :ai_personas, :allow_chat
5 | remove_columns :ai_personas, :mentionable
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class DiscourseAiAiEmbeddingsRoute extends DiscourseRoute {
4 | model() {
5 | return this.store.findAll("ai-embedding");
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/after-d-editor/composer-open.hbs:
--------------------------------------------------------------------------------
1 | {{#if this.isAiBotChat}}
2 | {{body-class this.aiBotClasses}}
3 | {{#if this.renderChatWarning}}
4 |
{{i18n
5 | "discourse_ai.ai_bot.pm_warning"
6 | }}
7 | {{/if}}
8 | {{/if}}
--------------------------------------------------------------------------------
/db/migrate/20240429065155_add_consolidated_question_llm_to_ai_persona.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddConsolidatedQuestionLlmToAiPersona < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :ai_personas, :question_consolidator_llm, :text, max_length: 2000
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/jobs/scheduled/remove_orphaned_embeddings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class RemoveOrphanedEmbeddings < ::Jobs::Scheduled
5 | every 1.week
6 |
7 | def execute(_args)
8 | DiscourseAi::Embeddings::Schema.remove_orphaned_data
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/serializers/llm_quota_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class LlmQuotaSerializer < ApplicationSerializer
4 | attributes :id, :group_id, :llm_model_id, :max_tokens, :max_usages, :duration_seconds, :group_name
5 |
6 | def group_name
7 | object.group.name
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/admin-discourse-ai-route-map.js:
--------------------------------------------------------------------------------
1 | export default {
2 | resource: "admin.dashboard",
3 | path: "/dashboard",
4 | map() {
5 | this.route("admin.dashboardSentiment", {
6 | path: "/dashboard/sentiment",
7 | resetNamespace: true,
8 | });
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/db/migrate/20250310172527_add_ai_search_discoveries_to_user_options.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddAiSearchDiscoveriesToUserOptions < ActiveRecord::Migration[7.2]
4 | def change
5 | add_column :user_options, :ai_search_discoveries, :boolean, default: true, null: false
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/serializers/ai_artifact_key_value_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiArtifactKeyValueSerializer < ApplicationSerializer
4 | attributes :id, :key, :value, :public, :user_id, :created_at, :updated_at
5 |
6 | def include_value?
7 | !options[:keys_only]
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs:
--------------------------------------------------------------------------------
1 | import RouteTemplate from "ember-route-template";
2 | import AiBotConversations from "discourse/plugins/discourse-ai/discourse/components/ai-bot-conversations";
3 |
4 | export default RouteTemplate();
5 |
--------------------------------------------------------------------------------
/spec/fabricators/llm_quota_usage_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Fabricator(:llm_quota_usage) do
4 | user
5 | llm_quota
6 | input_tokens_used { 0 }
7 | output_tokens_used { 0 }
8 | usages { 0 }
9 | started_at { Time.current }
10 | reset_at { Time.current + 1.day }
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20231031050538_add_topic_id_post_id_to_ai_audit_log.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddTopicIdPostIdToAiAuditLog < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :ai_api_audit_logs, :topic_id, :integer
6 | add_column :ai_api_audit_logs, :post_id, :integer
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/spec/fabricators/ai_tool_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Fabricator(:ai_tool) do
4 | name "github tool"
5 | tool_name "github_tool"
6 | description "This is a tool for GitHub"
7 | summary "This is a tool for GitHub"
8 | script "puts 'Hello, GitHub!'"
9 | created_by_id 1
10 | end
11 |
--------------------------------------------------------------------------------
/app/controllers/discourse_ai/admin/dashboard_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Admin
5 | class DashboardController < ::Admin::StaffController
6 | requires_plugin DiscourseAi::PLUGIN_NAME
7 |
8 | def sentiment
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/db/migrate/20240202010752_add_temperature_top_p_to_ai_personas.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddTemperatureTopPToAiPersonas < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :ai_personas, :temperature, :float, null: true
6 | add_column :ai_personas, :top_p, :float, null: true
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/20240404000838_add_metadata_to_rag_document_frament.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddMetadataToRagDocumentFrament < ActiveRecord::Migration[7.0]
4 | def change
5 | # limit is purely for safety
6 | add_column :rag_document_fragments, :metadata, :text, null: true, limit: 100_000
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/20240528132059_add_companion_user_to_llm_model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddCompanionUserToLlmModel < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :llm_models, :user_id, :integer
6 | add_column :llm_models, :enabled_chat_bot, :boolean, null: false, default: false
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/database/connection.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::DiscourseAi
4 | module Database
5 | class Connection
6 | def self.db
7 | pg_conn = PG.connect(SiteSetting.ai_embeddings_pg_connection_string)
8 | MiniSql::Connection.get(pg_conn)
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/db/migrate/20240104013944_add_params_to_completion_prompt.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddParamsToCompletionPrompt < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :completion_prompts, :temperature, :integer
6 | add_column :completion_prompts, :stop_sequences, :string, array: true
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/translation/post_raw_translator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | class PostRawTranslator < BaseTranslator
6 | private
7 |
8 | def persona_setting
9 | SiteSetting.ai_translation_post_raw_translator_persona
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20231128151234_recreate_generate_titles_prompt.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class RecreateGenerateTitlesPrompt < ActiveRecord::Migration[7.0]
4 | def up
5 | DB.exec("DELETE FROM completion_prompts WHERE id = -302")
6 | end
7 |
8 | def down
9 | raise ActiveRecord::IrreversibleMigration
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20240609232736_drop_commands_from_ai_personas.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class DropCommandsFromAiPersonas < ActiveRecord::Migration[7.0]
3 | def down
4 | raise ActiveRecord::IrreversibleMigration
5 | end
6 |
7 | def up
8 | Migration::ColumnDropper.execute_drop(:ai_personas, [:commands])
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/db/migrate/20250722082515_add_index_to_ai_topics_embeddings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddIndexToAiTopicsEmbeddings < ActiveRecord::Migration[7.2]
4 | def up
5 | add_index :ai_topics_embeddings, %i[topic_id model_id]
6 | end
7 |
8 | def down
9 | raise ActiveRecord::IrreversibleMigration
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/embeddings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Embeddings
5 | def self.enabled?
6 | SiteSetting.ai_embeddings_enabled && SiteSetting.ai_embeddings_selected_model.present? &&
7 | EmbeddingDefinition.exists?(id: SiteSetting.ai_embeddings_selected_model)
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/translation/short_text_translator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | class ShortTextTranslator < BaseTranslator
6 | private
7 |
8 | def persona_setting
9 | SiteSetting.ai_translation_short_text_translator_persona
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/translation/topic_title_translator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | class TopicTitleTranslator < BaseTranslator
6 | private
7 |
8 | def persona_setting
9 | SiteSetting.ai_translation_topic_title_translator_persona
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/edit.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/db/migrate/20231123224203_switch_to_generic_completion_prompts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class SwitchToGenericCompletionPrompts < ActiveRecord::Migration[7.0]
4 | def change
5 | remove_column :completion_prompts, :provider, :text
6 |
7 | DB.exec("DELETE FROM completion_prompts WHERE (id < 0 AND id > -300)")
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/personas/creative.rb:
--------------------------------------------------------------------------------
1 | #frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Personas
5 | class Creative < Persona
6 | def tools
7 | []
8 | end
9 |
10 | def system_prompt
11 | <<~PROMPT
12 | You are a helpful bot
13 | PROMPT
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/db/migrate/20240913054440_add_rag_columns_to_ai_tools.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddRagColumnsToAiTools < ActiveRecord::Migration[7.1]
4 | def change
5 | add_column :ai_tools, :rag_chunk_tokens, :integer, null: false, default: 374
6 | add_column :ai_tools, :rag_chunk_overlap_tokens, :integer, null: false, default: 10
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/20250416215039_add_cost_metrics_to_llm_model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddCostMetricsToLlmModel < ActiveRecord::Migration[7.2]
4 | def change
5 | add_column :llm_models, :input_cost, :float
6 | add_column :llm_models, :cached_input_cost, :float
7 | add_column :llm_models, :output_cost, :float
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/post_migrate/20250113171444_drop_old_embedding_tables.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class DropOldEmbeddingTables < ActiveRecord::Migration[7.2]
3 | def up
4 | # Copy rag embeddings created during deploy.
5 | # noop. TODO(roman): Will follow-up with a new migration to drop these tables.
6 | end
7 |
8 | def down
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/assets/javascripts/initializers/ai-semantic-search.js:
--------------------------------------------------------------------------------
1 | import { apiInitializer } from "discourse/lib/api";
2 |
3 | export default apiInitializer("1.15.0", (api) => {
4 | api.modifyClass("component:search-result-entry", {
5 | pluginId: "discourse-ai",
6 |
7 | classNameBindings: ["bulkSelectEnabled", "post.generatedByAi:ai-result"],
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/db/migrate/20231117050928_add_system_and_priority_to_ai_personas.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddSystemAndPriorityToAiPersonas < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :ai_personas, :system, :boolean, null: false, default: false
6 | add_column :ai_personas, :priority, :boolean, null: false, default: false
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools.js:
--------------------------------------------------------------------------------
1 | import { service } from "@ember/service";
2 | import DiscourseRoute from "discourse/routes/discourse";
3 |
4 | export default class DiscourseAiToolsRoute extends DiscourseRoute {
5 | @service store;
6 |
7 | model() {
8 | return this.store.findAll("ai-tool");
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/jobs/regular/ai_spam_scan.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class AiSpamScan < ::Jobs::Base
5 | def execute(args)
6 | return if !args[:post_id]
7 | post = Post.find_by(id: args[:post_id])
8 | return if !post
9 |
10 | DiscourseAi::AiModeration::SpamScanner.perform_scan(post)
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/config/locales/server.sr.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | sr:
8 | discourse_ai:
9 | ai_bot:
10 | tool_summary:
11 | search: "Pretraži"
12 | time: "Vreme"
13 |
--------------------------------------------------------------------------------
/db/migrate/20250424035234_remove_old_settings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class RemoveOldSettings < ActiveRecord::Migration[7.2]
3 | def up
4 | execute <<~SQL
5 | DELETE FROM site_settings
6 | WHERE name IN ('ai_bot_enabled_chat_bots')
7 | SQL
8 | end
9 |
10 | def down
11 | raise ActiveRecord::IrreversibleMigration
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20250508182047_create_inferred_concepts_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class CreateInferredConceptsTable < ActiveRecord::Migration[7.2]
3 | def change
4 | create_table :inferred_concepts do |t|
5 | t.string :name, null: false
6 | t.timestamps
7 | end
8 |
9 | add_index :inferred_concepts, :name, unique: true
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/assets/stylesheets/modules/embeddings/common/semantic-related-topics.scss:
--------------------------------------------------------------------------------
1 | .related-topics {
2 | margin: 4.5em 0 1em;
3 | }
4 |
5 | .more-topics__container {
6 | h3 .d-icon {
7 | margin-right: 0.25em;
8 | color: var(--primary-high);
9 | font-size: var(--font-down-1);
10 | }
11 |
12 | .nav-pills .d-icon {
13 | font-size: var(--font-down-1);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/app/serializers/ai_features_persona_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiFeaturesPersonaSerializer < ApplicationSerializer
4 | attributes :id, :name, :allowed_groups
5 |
6 | def allowed_groups
7 | Group
8 | .where(id: object.allowed_group_ids)
9 | .pluck(:id, :name)
10 | .map { |id, name| { id: id, name: name } }
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features.js:
--------------------------------------------------------------------------------
1 | import { service } from "@ember/service";
2 | import DiscourseRoute from "discourse/routes/discourse";
3 |
4 | export default class AdminPluginsShowDiscourseAiFeatures extends DiscourseRoute {
5 | @service store;
6 |
7 | async model() {
8 | return this.store.findAll("ai-feature");
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/db/migrate/20241028034232_add_unique_ai_stream_conversation_user_id_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddUniqueAiStreamConversationUserIdIndex < ActiveRecord::Migration[7.1]
3 | def change
4 | add_index :user_custom_fields,
5 | [:value],
6 | unique: true,
7 | where: "name = 'ai-stream-conversation-unique-id'"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/post_migrate/20250210032351_post_migrate_persona_to_llm_model_id.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class PostMigratePersonaToLlmModelId < ActiveRecord::Migration[7.2]
3 | def up
4 | remove_column :ai_personas, :default_llm
5 | remove_column :ai_personas, :question_consolidator_llm
6 | end
7 |
8 | def down
9 | raise ActiveRecord::IrreversibleMigration
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20231120033747_remove_site_settings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class RemoveSiteSettings < ActiveRecord::Migration[7.0]
3 | def up
4 | DB.exec(<<~SQL, %w[ai_bot_enabled_chat_commands ai_bot_enabled_personas])
5 | DELETE FROM site_settings WHERE name IN (?)
6 | SQL
7 | end
8 |
9 | def down
10 | raise ActiveRecord::IrreversibleMigration
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/spec/lib/personas/researcher_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Personas::Researcher do
4 | let(:researcher) { subject }
5 |
6 | before { enable_current_plugin }
7 |
8 | it "renders schema" do
9 | expect(researcher.tools).to eq(
10 | [DiscourseAi::Personas::Tools::Google, DiscourseAi::Personas::Tools::WebBrowser],
11 | )
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20241128010221_add_cached_tokens_to_ai_api_audit_log.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddCachedTokensToAiApiAuditLog < ActiveRecord::Migration[7.2]
4 | def change
5 | add_column :ai_api_audit_logs, :cached_tokens, :integer
6 | add_index :ai_api_audit_logs, %i[created_at feature_name]
7 | add_index :ai_api_audit_logs, %i[created_at language_model]
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/translation/verbose_logger.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | class VerboseLogger
6 | def self.log(message, opts = { level: :warn })
7 | if SiteSetting.ai_translation_verbose_logs
8 | Rails.logger.send(opts[:level], "DiscourseAi::Translation: #{message}")
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/new.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/javascripts/lib/discourse-markdown/ai-tags.js:
--------------------------------------------------------------------------------
1 | export function setup(helper) {
2 | helper.allowList(["details[class=ai-quote]"]);
3 | helper.allowList([
4 | "div[class=ai-artifact]",
5 | "div[data-ai-artifact-id]",
6 | "div[data-ai-artifact-version]",
7 | "div[data-ai-artifact-autorun]",
8 | "div[data-ai-artifact-height]",
9 | "div[data-ai-artifact-width]",
10 | ]);
11 | }
12 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/ai-indicator-wave.gjs:
--------------------------------------------------------------------------------
1 | const indicatorDots = [".", ".", "."];
2 | const AiIndicatorWave =
3 | {{#if @loading}}
4 |
5 | {{#each indicatorDots as |dot|}}
6 | {{dot}}
7 | {{/each}}
8 |
9 | {{/if}}
10 | ;
11 |
12 | export default AiIndicatorWave;
13 |
--------------------------------------------------------------------------------
/db/migrate/20230519003106_post_custom_prompts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class PostCustomPrompts < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :post_custom_prompts do |t|
6 | t.integer :post_id, null: false
7 | t.json :custom_prompt, null: false
8 | t.timestamps
9 | end
10 |
11 | add_index :post_custom_prompts, :post_id, unique: true
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/config/locales/server.sq.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | sq:
8 | discourse_ai:
9 | ai_bot:
10 | tool_summary:
11 | search: "Kërko"
12 | time: "Koha"
13 | ai_staff_action_logger:
14 | removed: "u hoq"
15 |
--------------------------------------------------------------------------------
/db/migrate/20240322035907_add_images_to_ai_personas.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddImagesToAiPersonas < ActiveRecord::Migration[7.0]
4 | def change
5 | change_table :ai_personas do |t|
6 | add_column :ai_personas, :vision_enabled, :boolean, default: false, null: false
7 | add_column :ai_personas, :vision_max_pixels, :integer, default: 1_048_576, null: false
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-spam.js:
--------------------------------------------------------------------------------
1 | import { service } from "@ember/service";
2 | import { ajax } from "discourse/lib/ajax";
3 | import DiscourseRoute from "discourse/routes/discourse";
4 |
5 | export default class DiscourseAiSpamRoute extends DiscourseRoute {
6 | @service store;
7 |
8 | model() {
9 | return ajax("/admin/plugins/discourse-ai/ai-spam.json");
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-usage.js:
--------------------------------------------------------------------------------
1 | import { service } from "@ember/service";
2 | import { ajax } from "discourse/lib/ajax";
3 | import DiscourseRoute from "discourse/routes/discourse";
4 |
5 | export default class DiscourseAiUsageRoute extends DiscourseRoute {
6 | @service store;
7 |
8 | model() {
9 | return ajax("/admin/plugins/discourse-ai/ai-usage.json");
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/config/locales/server.sl.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | sl:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Datum"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Išči"
15 | time: "Čas"
16 |
--------------------------------------------------------------------------------
/db/migrate/20250620073222_specify_rate_frequency_in_backfill_setting.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class SpecifyRateFrequencyInBackfillSetting < ActiveRecord::Migration[7.2]
4 | def up
5 | execute "UPDATE site_settings SET name = 'ai_translation_backfill_hourly_rate' WHERE name = 'ai_translation_backfill_rate'"
6 | end
7 |
8 | def down
9 | raise ActiveRecord::IrreversibleMigration
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/completions/endpoints/mistral.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Completions
5 | module Endpoints
6 | class Mistral < OpenAi
7 | def self.can_contact?(model_provider)
8 | model_provider == "mistral"
9 | end
10 |
11 | def provider_id
12 | AiApiAuditLog::Provider::Mistral
13 | end
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/config/locales/server.sw.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | sw:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Tarehe"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Tafuta"
15 | time: "Muda"
16 |
--------------------------------------------------------------------------------
/db/migrate/20240912052713_add_target_to_rag_document_fragment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddTargetToRagDocumentFragment < ActiveRecord::Migration[7.1]
4 | def change
5 | add_column :rag_document_fragments, :target_id, :integer, null: true
6 | add_column :rag_document_fragments, :target_type, :string, limit: 800, null: true
7 | add_index :rag_document_fragments, %i[target_type target_id]
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "devDependencies": {
4 | "@discourse/lint-configs": "2.11.1",
5 | "ember-template-lint": "7.0.1",
6 | "eslint": "9.22.0",
7 | "prettier": "3.5.3",
8 | "stylelint": "16.16.0"
9 | },
10 | "engines": {
11 | "node": ">= 22",
12 | "npm": "please-use-pnpm",
13 | "yarn": "please-use-pnpm",
14 | "pnpm": "9.x"
15 | },
16 | "packageManager": "pnpm@9.15.5"
17 | }
18 |
--------------------------------------------------------------------------------
/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | #
3 | class AddUserIdMentionableDefaultLlmToAiPersonas < ActiveRecord::Migration[7.0]
4 | def change
5 | change_table :ai_personas do |t|
6 | t.integer :user_id, null: true
7 | t.boolean :mentionable, default: false, null: false
8 | t.text :default_llm, null: true, length: 250
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20241031180044_set_origin_for_existing_ai_summaries.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class SetOriginForExistingAiSummaries < ActiveRecord::Migration[7.1]
3 | def up
4 | DB.exec <<~SQL
5 | UPDATE ai_summaries
6 | SET origin = CASE WHEN summary_type = 0 THEN 0 ELSE 1 END
7 | WHERE origin IS NULL
8 | SQL
9 | end
10 |
11 | def down
12 | raise ActiveRecord::IrreversibleMigration
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/db/post_migrate/20250115181147_drop_ai_summaries_content_range.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class DropAiSummariesContentRange < ActiveRecord::Migration[7.2]
3 | DROPPED_COLUMNS = { ai_summaries: %i[content_range] }
4 |
5 | def up
6 | DROPPED_COLUMNS.each { |table, columns| Migration::ColumnDropper.execute_drop(table, columns) }
7 | end
8 |
9 | def down
10 | raise ActiveRecord::IrreversibleMigration
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | app/assets/stylesheets/vendor/
2 | documentation/
3 | package.json
4 | config/locales/**/*.yml
5 | !config/locales/**/*.en*.yml
6 | script/import_scripts/**/*.yml
7 |
8 | plugins/**/lib/javascripts/locale
9 | public/
10 | !/app/assets/javascripts/discourse/public
11 | vendor/
12 | app/assets/javascripts/discourse/tests/fixtures
13 | spec/
14 | node_modules/
15 | dist/
16 | tmp/
17 |
18 | **/*.rb
19 | **/*.html
20 | **/*.json
21 | **/*.md
22 |
--------------------------------------------------------------------------------
/db/migrate/20240503042558_add_chat_message_custom_prompt.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddChatMessageCustomPrompt < ActiveRecord::Migration[7.0]
3 | def change
4 | create_table :chat_message_custom_prompts do |t|
5 | t.bigint :message_id, null: false
6 | t.json :custom_prompt, null: false
7 | t.timestamps
8 | end
9 |
10 | add_index :chat_message_custom_prompts, :message_id, unique: true
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/db/post_migrate/20240809162837_rename_ai_helper_enabled_setting.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class RenameAiHelperEnabledSetting < ActiveRecord::Migration[7.1]
4 | def up
5 | execute "UPDATE site_settings SET name = 'ai_helper_enabled' WHERE name = 'composer_ai_helper_enabled'"
6 | end
7 |
8 | def down
9 | execute "UPDATE site_settings SET name = 'composer_ai_helper_enabled' WHERE name = 'ai_helper_enabled'"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/ai-tool-selector.gjs:
--------------------------------------------------------------------------------
1 | import { hash } from "@ember/helper";
2 | import MultiSelect from "select-kit/components/multi-select";
3 |
4 | const AiToolSelector =
5 |
11 | ;
12 |
13 | export default AiToolSelector;
14 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/reviewable-ai-chat-message.js:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
3 |
4 | export default class ReviewableAiChatMessage extends Component {
5 | get chatChannel() {
6 | if (!this.args.reviewable.chat_channel) {
7 | return;
8 | }
9 | return ChatChannel.create(this.args.reviewable.chat_channel);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/db/migrate/20250407125756_set_correct_default_for_short_summarizer_persona.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class SetCorrectDefaultForShortSummarizerPersona < ActiveRecord::Migration[7.2]
3 | def up
4 | execute <<~SQL
5 | UPDATE ai_personas
6 | SET allowed_group_ids = ARRAY[0]
7 | WHERE id = -12 AND allowed_group_ids = '{}'
8 | SQL
9 | end
10 |
11 | def down
12 | raise ActiveRecord::IrreversibleMigration
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/tasks/modules/sentiment/backfill.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | desc "Backfill sentiment for all posts"
4 | task "ai:sentiment:backfill", [:start_post] => [:environment] do |_, args|
5 | DiscourseAi::Sentiment::PostClassification
6 | .backfill_query(from_post_id: args[:start_post].to_i)
7 | .find_in_batches do |batch|
8 | print "."
9 | DiscourseAi::Sentiment::PostClassification.new.bulk_classify!(batch)
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/jobs/scheduled/category_localization_backfill.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class CategoryLocalizationBackfill < ::Jobs::Scheduled
5 | every 1.hour
6 | cluster_concurrency 1
7 |
8 | def execute(args)
9 | return if !DiscourseAi::Translation.backfill_enabled?
10 | limit = SiteSetting.ai_translation_backfill_hourly_rate
11 |
12 | Jobs.enqueue(:localize_categories, limit:)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/post/ai-persona-flair.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { isGPTBot } from "../../lib/ai-bot-helper";
3 |
4 | export default class AiPersonaFlair extends Component {
5 | static shouldRender(args) {
6 | return isGPTBot(args.post.user);
7 | }
8 |
9 |
10 |
11 | {{@outletArgs.post.topic.ai_persona_name}}
12 |
13 |
14 | }
15 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs:
--------------------------------------------------------------------------------
1 | import icon from "discourse/helpers/d-icon";
2 | import { i18n } from "discourse-i18n";
3 |
4 | const SearchResultDecoration =
5 |
9 | {{icon "discourse-sparkles"}}
10 |
11 | ;
12 |
13 | export default SearchResultDecoration;
14 |
--------------------------------------------------------------------------------
/db/migrate/20240504222307_create_llm_model_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateLlmModelTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :llm_models do |t|
6 | t.string :display_name
7 | t.string :name, null: false
8 | t.string :provider, null: false
9 | t.string :tokenizer, null: false
10 | t.integer :max_prompt_tokens, null: false
11 | t.timestamps
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/spec/models/llm_model_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe LlmModel do
4 | before { enable_current_plugin }
5 |
6 | describe "api_key" do
7 | fab!(:llm_model) { Fabricate(:seeded_model) }
8 |
9 | before { ENV["DISCOURSE_AI_SEEDED_LLM_API_KEY_2"] = "blabla" }
10 |
11 | it "should use environment variable over database value if seeded LLM" do
12 | expect(llm_model.api_key).to eq("blabla")
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/db/migrate/20240309034752_create_rag_document_fragment_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateRagDocumentFragmentTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :rag_document_fragments do |t|
6 | t.text :fragment, null: false
7 | t.integer :upload_id, null: false
8 | t.integer :ai_persona_id, null: false
9 | t.integer :fragment_number, null: false
10 | t.timestamps
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class RenamedExperimentalAiBotSetting < ActiveRecord::Migration[7.2]
3 | def up
4 | execute "UPDATE site_settings SET name = 'ai_bot_enable_dedicated_ux' WHERE name = 'ai_enable_experimental_bot_ux'"
5 | end
6 |
7 | def down
8 | execute "UPDATE site_settings SET name = 'ai_enable_experimental_bot_ux' WHERE name = 'ai_bot_enable_dedicated_ux'"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/db/migrate/20230831033812_rename_ai_helper_add_ai_pm_to_header_setting.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class RenameAiHelperAddAiPmToHeaderSetting < ActiveRecord::Migration[7.0]
4 | def up
5 | execute "UPDATE site_settings SET name = 'ai_bot_add_to_header' WHERE name = 'ai_helper_add_ai_pm_to_header'"
6 | end
7 |
8 | def down
9 | execute "UPDATE site_settings SET name = 'ai_helper_add_ai_pm_to_header' WHERE name = 'ai_bot_add_to_header'"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20241025135522_alter_ai_ids_to_bigint.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AlterAiIdsToBigint < ActiveRecord::Migration[7.1]
4 | def up
5 | change_column :ai_document_fragment_embeddings, :rag_document_fragment_id, :bigint
6 | change_column :classification_results, :target_id, :bigint
7 | change_column :rag_document_fragments, :target_id, :bigint
8 | end
9 |
10 | def down
11 | raise ActiveRecord::IrreversibleMigration
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20241126033812_rename_ai_gist_batch_setting.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class RenameAiGistBatchSetting < ActiveRecord::Migration[7.0]
4 | def up
5 | execute "UPDATE site_settings SET name = 'ai_summary_gists_allowed_groups' WHERE name = 'ai_hot_topic_gists_allowed_groups'"
6 | end
7 |
8 | def down
9 | execute "UPDATE site_settings SET name = 'ai_hot_topic_gists_allowed_groups' WHERE name = 'ai_summary_gists_allowed_groups'"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/system/page_objects/pages/discourse_ai/header.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module PageObjects
4 | module Pages
5 | module DiscourseAi
6 | class Header < ::PageObjects::Pages::Header
7 | def click_bot_button
8 | find(".ai-bot-button").click
9 | end
10 |
11 | def has_icon_in_bot_button?(icon:)
12 | page.has_css?(".ai-bot-button .d-icon-#{icon}")
13 | end
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/db/migrate/20230322142028_make_dropped_value_column_nullable.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class MakeDroppedValueColumnNullable < ActiveRecord::Migration[7.0]
3 | def up
4 | if column_exists?(:completion_prompts, :value)
5 | Migration::SafeMigrate.disable!
6 | change_column_null :completion_prompts, :value, true
7 | Migration::SafeMigrate.enable!
8 | end
9 | end
10 |
11 | def down
12 | raise ActiveRecord::IrreversibleMigration
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/assets/javascripts/initializers/ai-search-discoveries.js:
--------------------------------------------------------------------------------
1 | import { apiInitializer } from "discourse/lib/api";
2 |
3 | export default apiInitializer((api) => {
4 | const currentUser = api.getCurrentUser();
5 | const settings = api.container.lookup("service:site-settings");
6 |
7 | if (
8 | !settings.ai_bot_enabled ||
9 | !currentUser?.can_use_ai_bot_discover_persona
10 | ) {
11 | return;
12 | }
13 |
14 | api.addSaveableUserOptionField("ai_search_discoveries");
15 | });
16 |
--------------------------------------------------------------------------------
/db/post_migrate/20240809163303_rename_ai_helper_allowed_groups_setting.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class RenameAiHelperAllowedGroupsSetting < ActiveRecord::Migration[7.1]
4 | def up
5 | execute "UPDATE site_settings SET name = 'composer_ai_helper_allowed_groups' WHERE name = 'ai_helper_allowed_groups'"
6 | end
7 |
8 | def down
9 | execute "UPDATE site_settings SET name = 'ai_helper_allowed_groups' WHERE name = 'composer_ai_helper_allowed_groups'"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/jobs/regular/manage_embedding_def_search_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::Jobs
4 | class ManageEmbeddingDefSearchIndex < ::Jobs::Base
5 | def execute(args)
6 | embedding_def = EmbeddingDefinition.find_by(id: args[:id])
7 | return if embedding_def.nil?
8 | return if DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_def)
9 |
10 | DiscourseAi::Embeddings::Schema.prepare_search_indexes(embedding_def)
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/config/locales/server.lv.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | lv:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Datums"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Meklēt"
15 | time: "Laiks"
16 | ai_staff_action_logger:
17 | removed: "noņemts"
18 |
--------------------------------------------------------------------------------
/config/locales/server.th.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | th:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "วันที่"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "ค้นหา"
15 | time: "เวลา"
16 | ai_staff_action_logger:
17 | updated: "อัปเดตแล้ว"
18 |
--------------------------------------------------------------------------------
/db/migrate/20230314184514_migrate_discourse_ai_reviewables.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class MigrateDiscourseAiReviewables < ActiveRecord::Migration[7.0]
3 | def up
4 | DB.exec("UPDATE reviewables SET type='ReviewableAiPost' WHERE type='ReviewableAIPost'")
5 | DB.exec(
6 | "UPDATE reviewables SET type='ReviewableAiChatMessage' WHERE type='ReviewableAIChatMessage'",
7 | )
8 | end
9 |
10 | def down
11 | raise ActiveRecord::IrreversibleMigration
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20230424055354_create_ai_api_audit_logs.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateAiApiAuditLogs < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :ai_api_audit_logs do |t|
6 | t.integer :provider_id, null: false
7 | t.integer :user_id
8 | t.integer :request_tokens
9 | t.integer :response_tokens
10 | t.string :raw_request_payload
11 | t.string :raw_response_payload
12 | t.timestamps
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/jobs/scheduled/topic_localization_backfill.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class TopicLocalizationBackfill < ::Jobs::Scheduled
5 | every 5.minutes
6 | cluster_concurrency 1
7 |
8 | def execute(args)
9 | return if !DiscourseAi::Translation.backfill_enabled?
10 |
11 | limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals
12 | Jobs.enqueue(:localize_topics, limit:)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/ai-llm-editor.gjs:
--------------------------------------------------------------------------------
1 | import BackButton from "discourse/components/back-button";
2 | import AiLlmEditorForm from "./ai-llm-editor-form";
3 |
4 | const AiLlmEditor =
5 |
9 |
14 | ;
15 |
16 | export default AiLlmEditor;
17 |
--------------------------------------------------------------------------------
/config/locales/server.bs_BA.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | bs_BA:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Datum"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Pretraži"
15 | time: "Vrijeme"
16 | ai_staff_action_logger:
17 | updated: "ažurirano"
18 |
--------------------------------------------------------------------------------
/db/post_migrate/20241206115958_rebake_shared_ai_conversation_oneboxes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class RebakeSharedAiConversationOneboxes < ActiveRecord::Migration[7.2]
3 | def up
4 | # Safe marking for rebake using raw SQL
5 | DB.exec(<<~SQL)
6 | UPDATE posts
7 | SET baked_version = NULL
8 | WHERE raw LIKE '%/discourse-ai/ai-bot/shared-ai-conversations/%';
9 | SQL
10 | end
11 |
12 | def down
13 | raise ActiveRecord::IrreversibleMigration
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/configuration/persona_enumerator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "enum_site_setting"
4 |
5 | module DiscourseAi
6 | module Configuration
7 | class PersonaEnumerator < ::EnumSiteSetting
8 | def self.valid_value?(val)
9 | true
10 | end
11 |
12 | def self.values
13 | AiPersona
14 | .all_personas(enabled_only: false)
15 | .map { |persona| { name: persona.name, value: persona.id } }
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/translation/topic_locale_detector.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | class TopicLocaleDetector
6 | def self.detect_locale(topic)
7 | return if topic.blank?
8 |
9 | detected_locale = LanguageDetector.new(topic.title.dup, topic:).detect
10 | locale = LocaleNormalizer.normalize_to_i18n(detected_locale)
11 | topic.update_column(:locale, locale)
12 | locale
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/ai-llm-selector.gjs:
--------------------------------------------------------------------------------
1 | import { hash } from "@ember/helper";
2 | import ComboBox from "select-kit/components/combo-box";
3 |
4 | const AiLlmSelector =
5 |
15 | ;
16 |
17 | export default AiLlmSelector;
18 |
--------------------------------------------------------------------------------
/lib/configuration/embedding_defs_validator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Configuration
5 | class EmbeddingDefsValidator
6 | def initialize(opts = {})
7 | @opts = opts
8 | end
9 |
10 | def valid_value?(val)
11 | val.present? || !SiteSetting.ai_embeddings_enabled
12 | end
13 |
14 | def error_message
15 | I18n.t("discourse_ai.embeddings.configuration.disable_embeddings")
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/db/migrate/20250115173456_add_highest_target_number_to_ai_summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddHighestTargetNumberToAiSummary < ActiveRecord::Migration[7.2]
3 | def up
4 | add_column :ai_summaries, :highest_target_number, :integer, null: false, default: 1
5 |
6 | execute <<~SQL
7 | UPDATE ai_summaries SET highest_target_number = GREATEST(UPPER(content_range) - 1, 1)
8 | SQL
9 | end
10 |
11 | def down
12 | drop_column :ai_summaries, :highest_target_number
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/config/locales/server.nb_NO.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | nb_NO:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Dato"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Søk"
15 | time: "Tid"
16 | ai_staff_action_logger:
17 | updated: "oppdatert"
18 | removed: "fjernet"
19 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/model-accuracies.hbs:
--------------------------------------------------------------------------------
1 | {{#if @accuracies}}
2 |
3 |
4 | {{#each-in @accuracies as |model acc|}}
5 |
6 | | {{i18n "discourse_ai.reviewables.model_used"}} |
7 | {{model}} |
8 | {{i18n "discourse_ai.reviewables.accuracy"}} |
9 | {{acc}}% |
10 |
11 | {{/each-in}}
12 |
13 |
14 | {{/if}}
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/before-create-topic-button/topic-list-gist-toggle.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { service } from "@ember/service";
3 | import AiGistToggle from "../../components/ai-gist-toggle";
4 |
5 | export default class AiTopicGist extends Component {
6 | @service topicThumbnails; // avoid Topic Thumbnails theme component
7 |
8 | get shouldShow() {
9 | return !this.topicThumbnails?.enabledForRoute;
10 | }
11 |
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/config/locales/server.ro.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | ro:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Dată"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Căutare"
15 | time: "Oră"
16 | summarize: "Rezumat"
17 | ai_staff_action_logger:
18 | updated: "actualizat"
19 |
--------------------------------------------------------------------------------
/db/migrate/20230320122645_delete_duplicated_seeded_prompts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class DeleteDuplicatedSeededPrompts < ActiveRecord::Migration[7.0]
4 | def up
5 | DB.exec <<~SQL
6 | DELETE FROM completion_prompts
7 | WHERE (
8 | (id = 1 AND name = 'translate') OR
9 | (id = 2 AND name = 'generate_titles') OR
10 | (id = 3 AND name = 'proofread')
11 | )
12 | SQL
13 | end
14 |
15 | def down
16 | raise ActiveRecord::IrreversibleMigration
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/topic-list-before-category/ai-topic-gist-placement.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import AiTopicGist from "../../components/ai-topic-gist";
3 |
4 | export default class AiTopicGistPlacement extends Component {
5 | static shouldRender(_outletArgs, helper) {
6 | const settings = helper.siteSettings;
7 | return settings.discourse_ai_enabled && settings.ai_summarization_enabled;
8 | }
9 |
10 |
11 | }
12 |
--------------------------------------------------------------------------------
/config/locales/server.bg.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | bg:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Дата"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Търсене"
15 | time: "Време "
16 | summarize: "Обобщаване"
17 | ai_staff_action_logger:
18 | removed: "премахнато"
19 |
--------------------------------------------------------------------------------
/config/locales/server.el.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | el:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Ημερομηνία"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Αναζήτηση"
15 | time: "Ώρα"
16 | ai_staff_action_logger:
17 | updated: "ενημερώθηκε"
18 | removed: "αφαιρέθηκε"
19 |
--------------------------------------------------------------------------------
/db/migrate/20240606151348_create_ai_summaries_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateAiSummariesTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :ai_summaries do |t|
6 | t.integer :target_id, null: false
7 | t.string :target_type, null: false
8 | t.int4range :content_range
9 | t.string :summarized_text, null: false
10 | t.string :original_content_sha, null: false
11 | t.string :algorithm, null: false
12 | t.timestamps
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/completions/dialects/fake.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Completions
5 | module Dialects
6 | class Fake < Dialect
7 | class << self
8 | def can_translate?(llm_model)
9 | llm_model.provider == "fake"
10 | end
11 | end
12 |
13 | def tokenizer
14 | DiscourseAi::Tokenizer::OpenAiTokenizer
15 | end
16 |
17 | def translate
18 | ""
19 | end
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/app/jobs/scheduled/post_localization_backfill.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class PostLocalizationBackfill < ::Jobs::Scheduled
5 | every 5.minutes
6 | cluster_concurrency 1
7 |
8 | def execute(args)
9 | return if !DiscourseAi::Translation.backfill_enabled?
10 |
11 | limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals
12 | return if limit == 0
13 |
14 | Jobs.enqueue(:localize_posts, limit:)
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/lib/ai-streamer/updaters/stream-updater.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Interface needed to implement for a streaming updater
3 | */
4 | export default class StreamUpdater {
5 | set streaming(value) {
6 | throw "not implemented";
7 | }
8 |
9 | async setCooked() {
10 | throw "not implemented";
11 | }
12 |
13 | async setRaw() {
14 | throw "not implemented";
15 | }
16 |
17 | get element() {
18 | throw "not implemented";
19 | }
20 |
21 | get raw() {
22 | throw "not implemented";
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/lib/configuration/embedding_defs_enumerator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "enum_site_setting"
4 |
5 | module DiscourseAi
6 | module Configuration
7 | class EmbeddingDefsEnumerator < ::EnumSiteSetting
8 | def self.valid_value?(val)
9 | true
10 | end
11 |
12 | def self.values
13 | DB.query_hash(<<~SQL).map(&:symbolize_keys)
14 | SELECT display_name AS name, id AS value
15 | FROM embedding_definitions
16 | SQL
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/translation/post_locale_detector.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | class PostLocaleDetector
6 | def self.detect_locale(post)
7 | return if post.blank?
8 |
9 | text = PostDetectionText.get_text(post)
10 | detected_locale = LanguageDetector.new(text, post:).detect
11 | locale = LocaleNormalizer.normalize_to_i18n(detected_locale)
12 | post.update_column(:locale, locale)
13 | locale
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/jobs/regular/post_sentiment_analysis.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::Jobs
4 | class PostSentimentAnalysis < ::Jobs::Base
5 | sidekiq_options queue: "low"
6 |
7 | def execute(args)
8 | return unless SiteSetting.ai_sentiment_enabled
9 | return if (post_id = args[:post_id]).blank?
10 |
11 | post = Post.find_by(id: post_id, post_type: Post.types[:regular])
12 | return if post&.raw.blank?
13 |
14 | DiscourseAi::Sentiment::PostClassification.new.classify!(post)
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/serializers/ai_topic_summary_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiTopicSummarySerializer < ApplicationSerializer
4 | attributes :summarized_text,
5 | :algorithm,
6 | :outdated,
7 | :can_regenerate,
8 | :new_posts_since_summary,
9 | :updated_at
10 |
11 | def can_regenerate
12 | scope.can_request_summary?
13 | end
14 |
15 | def new_posts_since_summary
16 | object.target.highest_post_number.to_i - object.highest_target_number.to_i
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/search-menu-before-advanced-search/ai-quick-search-loader.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { service } from "@ember/service";
3 | import loadingSpinner from "discourse/helpers/loading-spinner";
4 |
5 | export default class AiQuickSearchLoader extends Component {
6 | @service quickSearch;
7 |
8 |
9 | {{#if this.quickSearch.loading}}
10 |
11 | {{loadingSpinner}}
12 |
13 | {{/if}}
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/db/migrate/20250715165701_update_open_ai_embeddings_tokenizer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class UpdateOpenAiEmbeddingsTokenizer < ActiveRecord::Migration[7.2]
3 | def up
4 | execute <<~SQL
5 | UPDATE embedding_definitions
6 | SET tokenizer_class = 'DiscourseAi::Tokenizer::OpenAiCl100kTokenizer'
7 | WHERE url LIKE '%https://api.openai.com/%' AND tokenizer_class <> 'DiscourseAi::Tokenizer::OpenAiCl100kTokenizer'
8 | SQL
9 | end
10 |
11 | def down
12 | raise ActiveRecord::IrreversibleMigration
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/spec/fabricators/ai_summary_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Fabricator(:ai_summary) do
4 | summarized_text "complete summary"
5 | original_content_sha "123"
6 | algorithm "test"
7 | target { Fabricate(:topic) }
8 | summary_type AiSummary.summary_types[:complete]
9 | origin AiSummary.origins[:human]
10 | highest_target_number 1
11 | end
12 |
13 | Fabricator(:topic_ai_gist, from: :ai_summary) do
14 | summarized_text "gist"
15 | summary_type AiSummary.summary_types[:gist]
16 | origin AiSummary.origins[:system]
17 | end
18 |
--------------------------------------------------------------------------------
/config/locales/server.pt.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | pt:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Data"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Pesquisar"
15 | time: "Hora"
16 | summarize: "Resumir"
17 | ai_staff_action_logger:
18 | updated: "atualizado"
19 | removed: "removido"
20 |
--------------------------------------------------------------------------------
/db/migrate/20230307125342_created_model_accuracy_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreatedModelAccuracyTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :model_accuracies do |t|
6 | t.string :model, null: false
7 | t.string :classification_type, null: false
8 | t.integer :flags_agreed, null: false, default: 0
9 | t.integer :flags_disagreed, null: false, default: 0
10 |
11 | t.timestamps
12 | end
13 |
14 | add_index :model_accuracies, %i[model], unique: true
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/db/migrate/20241206051225_add_ai_spam_logs.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddAiSpamLogs < ActiveRecord::Migration[7.2]
3 | def change
4 | create_table :ai_spam_logs do |t|
5 | t.bigint :post_id, null: false
6 | t.bigint :llm_model_id, null: false
7 | t.bigint :ai_api_audit_log_id
8 | t.bigint :reviewable_id
9 | t.boolean :is_spam, null: false
10 | t.string :payload, null: false, default: "", limit: 20_000
11 | t.timestamps
12 | end
13 |
14 | add_index :ai_spam_logs, :post_id
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/config/locales/server.vi.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | vi:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Ngày"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Tìm kiếm"
15 | time: "Thời gian"
16 | summarize: "Tóm tắt"
17 | ai_staff_action_logger:
18 | updated: "đã cập nhật"
19 | removed: "đã xóa"
20 |
--------------------------------------------------------------------------------
/lib/topic_extensions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module TopicExtensions
5 | extend ActiveSupport::Concern
6 |
7 | prepended do
8 | has_many :ai_summaries, as: :target
9 |
10 | has_one :ai_gist_summary,
11 | -> { where(summary_type: AiSummary.summary_types[:gist]) },
12 | class_name: "AiSummary",
13 | as: :target
14 |
15 | has_many :inferred_concept_topics
16 | has_many :inferred_concepts, through: :inferred_concept_topics
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/translation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | def self.enabled?
6 | SiteSetting.discourse_ai_enabled && SiteSetting.ai_translation_enabled &&
7 | SiteSetting.ai_translation_model.present? &&
8 | SiteSetting.content_localization_supported_locales.present?
9 | end
10 |
11 | def self.backfill_enabled?
12 | enabled? && SiteSetting.ai_translation_backfill_hourly_rate > 0 &&
13 | SiteSetting.ai_translation_backfill_max_age_days > 0
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/db/migrate/20230316160714_create_completion_prompt_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class CreateCompletionPromptTable < ActiveRecord::Migration[7.0]
3 | def change
4 | create_table :completion_prompts do |t|
5 | t.string :name, null: false
6 | t.string :translated_name
7 | t.integer :prompt_type, null: false, default: 0
8 | t.text :value, null: false
9 | t.boolean :enabled, null: false, default: true
10 | t.timestamps
11 | end
12 |
13 | add_index :completion_prompts, %i[name], unique: true
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/automation/llm_tool_triage.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module DiscourseAi
3 | module Automation
4 | module LlmToolTriage
5 | def self.handle(post:, tool_id:, automation: nil)
6 | tool = AiTool.find_by(id: tool_id)
7 | return if !tool
8 | return if !tool.parameters.blank?
9 |
10 | context = DiscourseAi::Personas::BotContext.new(post: post)
11 |
12 | runner = tool.runner({}, llm: nil, bot_user: Discourse.system_user, context: context)
13 | runner.invoke
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/serializers/reviewable_ai_chat_message_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_dependency "reviewable_serializer"
4 |
5 | class ReviewableAiChatMessageSerializer < ReviewableSerializer
6 | payload_attributes :accuracies, :message_cooked
7 | target_attributes :cooked
8 | attributes :target_id
9 |
10 | has_one :chat_channel, serializer: AiChatChannelSerializer, root: false, embed: :objects
11 |
12 | def chat_channel
13 | object.chat_message&.chat_channel
14 | end
15 |
16 | def target_id
17 | object.target&.id
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/config/locales/server.hr.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | hr:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Datum"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Pretraživanje"
15 | time: "Vrijeme"
16 | summarize: "Rezimirati"
17 | ai_staff_action_logger:
18 | updated: "ažurirano"
19 | removed: "uklonjeno"
20 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/routes/preferences-ai.js:
--------------------------------------------------------------------------------
1 | import { service } from "@ember/service";
2 | import { defaultHomepage } from "discourse/lib/utilities";
3 | import RestrictedUserRoute from "discourse/routes/restricted-user";
4 |
5 | export default class PreferencesAiRoute extends RestrictedUserRoute {
6 | @service siteSettings;
7 |
8 | setupController(controller, user) {
9 | if (!this.siteSettings.discourse_ai_enabled) {
10 | return this.router.transitionTo(`discovery.${defaultHomepage()}`);
11 | }
12 |
13 | controller.set("model", user);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/lib/post_extensions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module PostExtensions
5 | extend ActiveSupport::Concern
6 |
7 | prepended do
8 | has_many :classification_results, as: :target
9 |
10 | has_many :sentiment_classifications,
11 | -> { where(classification_type: "sentiment") },
12 | class_name: "ClassificationResult",
13 | as: :target
14 |
15 | has_many :inferred_concept_posts
16 | has_many :inferred_concepts, through: :inferred_concept_posts
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/app/serializers/ai_api_audit_log_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiApiAuditLogSerializer < ApplicationSerializer
4 | attributes :id,
5 | :provider_id,
6 | :user_id,
7 | :request_tokens,
8 | :response_tokens,
9 | :raw_request_payload,
10 | :raw_response_payload,
11 | :topic_id,
12 | :post_id,
13 | :feature_name,
14 | :language_model,
15 | :created_at,
16 | :prev_log_id,
17 | :next_log_id
18 | end
19 |
--------------------------------------------------------------------------------
/db/migrate/20231003155701_create_bge_topic_embeddings_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateBgeTopicEmbeddingsTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :ai_topic_embeddings_4_1, id: false do |t|
6 | t.integer :topic_id, null: false
7 | t.integer :model_version, null: false
8 | t.integer :strategy_version, null: false
9 | t.text :digest, null: false
10 | t.column :embeddings, "vector(1024)", null: false
11 | t.timestamps
12 |
13 | t.index :topic_id, unique: true
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/db/migrate/20240611170904_upgrade_pgvector_070.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class UpgradePgvector070 < ActiveRecord::Migration[7.0]
4 | def up
5 | minimum_target_version = "0.7.0"
6 | installed_version =
7 | DB.query_single("SELECT extversion FROM pg_extension WHERE extname = 'vector';").first
8 |
9 | if Gem::Version.new(installed_version) < Gem::Version.new(minimum_target_version)
10 | DB.exec("ALTER EXTENSION vector UPDATE TO '0.7.0';")
11 | end
12 | end
13 |
14 | def down
15 | raise ActiveRecord::IrreversibleMigration
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/db/migrate/20241206030229_add_ai_moderation_settings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddAiModerationSettings < ActiveRecord::Migration[7.2]
3 | def change
4 | create_enum :ai_moderation_setting_type, %w[spam nsfw custom]
5 |
6 | create_table :ai_moderation_settings do |t|
7 | t.enum :setting_type, enum_type: "ai_moderation_setting_type", null: false
8 | t.jsonb :data, default: {}
9 | t.bigint :llm_model_id, null: false
10 | t.timestamps
11 | end
12 |
13 | add_index :ai_moderation_settings, :setting_type, unique: true
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/serializers/ai_custom_tool_list_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiCustomToolListSerializer < ApplicationSerializer
4 | attributes :meta
5 |
6 | has_many :ai_tools, serializer: AiCustomToolSerializer, embed: :objects
7 |
8 | def meta
9 | {
10 | presets: AiTool.presets,
11 | llms: DiscourseAi::Configuration::LlmEnumerator.values_for_serialization,
12 | settings: {
13 | rag_images_enabled: SiteSetting.ai_rag_images_enabled,
14 | },
15 | }
16 | end
17 |
18 | def ai_tools
19 | object[:ai_tools]
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/db/migrate/20231227223301_create_gemini_topic_embeddings_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateGeminiTopicEmbeddingsTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :ai_topic_embeddings_5_1, id: false do |t|
6 | t.integer :topic_id, null: false
7 | t.integer :model_version, null: false
8 | t.integer :strategy_version, null: false
9 | t.text :digest, null: false
10 | t.column :embeddings, "vector(768)", null: false
11 | t.timestamps
12 |
13 | t.index :topic_id, unique: true
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/config/locales/server.be.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | be:
8 | reports:
9 | emotion_neutral:
10 | title: "\U0001F610 нейтральны"
11 | discourse_ai:
12 | ai_bot:
13 | tool_summary:
14 | search: "Пошук"
15 | sentiment:
16 | reports:
17 | post_emotion:
18 | neutral: "нейтральны \U0001F610"
19 | sentiment_analysis:
20 | neutral: "нейтральны"
21 |
--------------------------------------------------------------------------------
/db/migrate/20241104053017_add_ai_artifacts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddAiArtifacts < ActiveRecord::Migration[7.1]
3 | def change
4 | create_table :ai_artifacts do |t|
5 | t.integer :user_id, null: false
6 | t.integer :post_id, null: false
7 | t.string :name, null: false, limit: 255
8 | t.string :html, limit: 65_535 # ~64KB limit
9 | t.string :css, limit: 65_535 # ~64KB limit
10 | t.string :js, limit: 65_535 # ~64KB limit
11 | t.jsonb :metadata # For any additional properties
12 |
13 | t.timestamps
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/db/migrate/20230727170222_create_multilingual_topic_embeddings_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateMultilingualTopicEmbeddingsTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :ai_topic_embeddings_3_1, id: false do |t|
6 | t.integer :topic_id, null: false
7 | t.integer :model_version, null: false
8 | t.integer :strategy_version, null: false
9 | t.text :digest, null: false
10 | t.column :embeddings, "vector(1024)", null: false
11 | t.timestamps
12 |
13 | t.index :topic_id, unique: true
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/configuration/embeddings_module_validator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Configuration
5 | class EmbeddingsModuleValidator
6 | def initialize(opts = {})
7 | @opts = opts
8 | end
9 |
10 | def valid_value?(val)
11 | return true if val == "f"
12 | return true if Rails.env.test?
13 |
14 | SiteSetting.ai_embeddings_selected_model.present?
15 | end
16 |
17 | def error_message
18 | I18n.t("discourse_ai.embeddings.configuration.choose_model")
19 | end
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/ai-summary-skeleton.gjs:
--------------------------------------------------------------------------------
1 | import { i18n } from "discourse-i18n";
2 | import AiBlinkingAnimation from "./ai-blinking-animation";
3 | import AiIndicatorWave from "./ai-indicator-wave";
4 |
5 | const AiSummarySkeleton =
6 |
7 |
8 |
9 |
10 |
11 | {{i18n "summary.in_progress"}}
12 |
13 |
14 |
15 |
16 | ;
17 |
18 | export default AiSummarySkeleton;
19 |
--------------------------------------------------------------------------------
/db/migrate/20240708193243_fix_vllm_model_name.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class FixVllmModelName < ActiveRecord::Migration[7.1]
3 | def up
4 | vllm_mixtral_model_id = DB.query_single(<<~SQL).first
5 | SELECT id FROM llm_models WHERE name = 'mistralai/Mixtral'
6 | SQL
7 |
8 | DB.exec(<<~SQL, target_id: vllm_mixtral_model_id) if vllm_mixtral_model_id
9 | UPDATE llm_models
10 | SET name = 'mistralai/Mixtral-8x7B-Instruct-v0.1'
11 | WHERE id = :target_id
12 | SQL
13 | end
14 |
15 | def down
16 | raise ActiveRecord::IrreversibleMigration
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/jobs/regular/stream_discord_reply.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class StreamDiscordReply < ::Jobs::Base
5 | sidekiq_options retry: false
6 |
7 | def execute(args)
8 | interaction = args[:interaction]
9 |
10 | return unless SiteSetting.ai_discord_search_enabled
11 |
12 | if SiteSetting.ai_discord_search_mode == "persona"
13 | DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
14 | else
15 | DiscourseAi::Discord::Bot::Search.new(interaction).handle_interaction!
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/db/migrate/20241130003808_add_artifact_versions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddArtifactVersions < ActiveRecord::Migration[7.0]
3 | def change
4 | create_table :ai_artifact_versions do |t|
5 | t.bigint :ai_artifact_id, null: false
6 | t.integer :version_number, null: false
7 | t.string :html, limit: 65_535
8 | t.string :css, limit: 65_535
9 | t.string :js, limit: 65_535
10 | t.jsonb :metadata
11 | t.string :change_description
12 | t.timestamps
13 |
14 | t.index %i[ai_artifact_id version_number], unique: true
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/admin/adapters/ai-llm.js:
--------------------------------------------------------------------------------
1 | import RestAdapter from "discourse/adapters/rest";
2 |
3 | export default class Adapter extends RestAdapter {
4 | jsonMode = true;
5 |
6 | basePath() {
7 | return "/admin/plugins/discourse-ai/";
8 | }
9 |
10 | pathFor(store, type, findArgs) {
11 | // removes underscores which are implemented in base
12 | let path =
13 | this.basePath(store, type, findArgs) +
14 | store.pluralize(this.apiNameFor(type));
15 | return this.appendQueryParams(path, findArgs);
16 | }
17 |
18 | apiNameFor() {
19 | return "ai-llm";
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/lib/translation/category_locale_detector.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | class CategoryLocaleDetector
6 | def self.detect_locale(category)
7 | return if category.blank?
8 |
9 | text = [category.name, category.description].compact.join("\n\n")
10 | return if text.blank?
11 |
12 | detected_locale = LanguageDetector.new(text).detect
13 | locale = LocaleNormalizer.normalize_to_i18n(detected_locale)
14 | category.update_column(:locale, locale)
15 | locale
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/admin/adapters/ai-persona.js:
--------------------------------------------------------------------------------
1 | import RestAdapter from "discourse/adapters/rest";
2 |
3 | export default class Adapter extends RestAdapter {
4 | jsonMode = true;
5 |
6 | basePath() {
7 | return "/admin/plugins/discourse-ai/";
8 | }
9 |
10 | pathFor(store, type, findArgs) {
11 | // removes underscores which are implemented in base
12 | let path =
13 | this.basePath(store, type, findArgs) +
14 | store.pluralize(this.apiNameFor(type));
15 | return this.appendQueryParams(path, findArgs);
16 | }
17 |
18 | apiNameFor() {
19 | return "ai-persona";
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/admin/adapters/ai-tool.js:
--------------------------------------------------------------------------------
1 | import RestAdapter from "discourse/adapters/rest";
2 |
3 | export default class AiToolAdapter extends RestAdapter {
4 | jsonMode = true;
5 |
6 | basePath() {
7 | return "/admin/plugins/discourse-ai/";
8 | }
9 |
10 | pathFor(store, type, findArgs) {
11 | // removes underscores which are implemented in base
12 | let path =
13 | this.basePath(store, type, findArgs) +
14 | store.pluralize(this.apiNameFor(type));
15 | return this.appendQueryParams(path, findArgs);
16 | }
17 |
18 | apiNameFor() {
19 | return "ai-tool";
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/db/migrate/20250509000001_create_inferred_concept_posts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateInferredConceptPosts < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :inferred_concept_posts, id: false do |t|
6 | t.bigint :inferred_concept_id
7 | t.bigint :post_id
8 | t.timestamps
9 | end
10 |
11 | add_index :inferred_concept_posts,
12 | %i[post_id inferred_concept_id],
13 | unique: true,
14 | name: "index_inferred_concept_posts_uniqueness"
15 |
16 | add_index :inferred_concept_posts, :inferred_concept_id
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/spec/lib/personas/sql_helper_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Personas::SqlHelper do
4 | let(:sql_helper) { subject }
5 |
6 | before { enable_current_plugin }
7 |
8 | it "renders schema" do
9 | prompt = sql_helper.system_prompt
10 | expect(prompt).to include("posts(")
11 | expect(prompt).to include("topics(")
12 | expect(prompt).not_to include("translation_key") # not a priority table
13 | expect(prompt).to include("user_api_keys") # not a priority table
14 |
15 | expect(sql_helper.tools).to eq([DiscourseAi::Personas::Tools::DbSchema])
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/admin/adapters/ai-embedding.js:
--------------------------------------------------------------------------------
1 | import RestAdapter from "discourse/adapters/rest";
2 |
3 | export default class Adapter extends RestAdapter {
4 | jsonMode = true;
5 |
6 | basePath() {
7 | return "/admin/plugins/discourse-ai/";
8 | }
9 |
10 | pathFor(store, type, findArgs) {
11 | // removes underscores which are implemented in base
12 | let path =
13 | this.basePath(store, type, findArgs) +
14 | store.pluralize(this.apiNameFor(type));
15 | return this.appendQueryParams(path, findArgs);
16 | }
17 |
18 | apiNameFor() {
19 | return "ai-embedding";
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/ai-helper-loading.gjs:
--------------------------------------------------------------------------------
1 | import DButton from "discourse/components/d-button";
2 | import { i18n } from "discourse-i18n";
3 |
4 | const AiHelperLoading =
5 |
6 |
7 |
8 | {{i18n "discourse_ai.ai_helper.context_menu.loading"}}
9 |
10 |
16 |
17 | ;
18 |
19 | export default AiHelperLoading;
20 |
--------------------------------------------------------------------------------
/assets/javascripts/initializers/ai-gist-topic-list-class.js:
--------------------------------------------------------------------------------
1 | import { apiInitializer } from "discourse/lib/api";
2 |
3 | export default apiInitializer("1.15.0", (api) => {
4 | const gistService = api.container.lookup("service:gists");
5 |
6 | api.registerValueTransformer(
7 | "topic-list-item-class",
8 | ({ value, context }) => {
9 | const shouldShow =
10 | gistService.preference === "table-ai" && gistService.shouldShow;
11 |
12 | if (context.topic.get("ai_topic_gist") && shouldShow) {
13 | value.push("excerpt-expanded");
14 | }
15 |
16 | return value;
17 | }
18 | );
19 | });
20 |
--------------------------------------------------------------------------------
/db/migrate/20240409035951_add_rag_params_to_ai_persona.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddRagParamsToAiPersona < ActiveRecord::Migration[7.0]
4 | def change
5 | # the default fits without any data loss in a 384 token vector representation
6 | # larger embedding models can easily fit larger chunks so this is configurable
7 | add_column :ai_personas, :rag_chunk_tokens, :integer, null: false, default: 374
8 | add_column :ai_personas, :rag_chunk_overlap_tokens, :integer, null: false, default: 10
9 | add_column :ai_personas, :rag_conversation_chunks, :integer, null: false, default: 10
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/configuration/spam_detection_validator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Configuration
5 | class SpamDetectionValidator
6 | def initialize(opts = {})
7 | @opts = opts
8 | end
9 |
10 | def valid_value?(val)
11 | # only validate when enabling spam detection
12 | return true if val == "f" || val == "false"
13 | return true if AiModerationSetting.spam
14 |
15 | false
16 | end
17 |
18 | def error_message
19 | I18n.t("discourse_ai.spam_detection.configuration_missing")
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-new.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class AdminPluginsShowDiscourseAiEmbeddingsNew extends DiscourseRoute {
4 | async model() {
5 | const record = this.store.createRecord("ai-embedding");
6 | record.provider_params = {};
7 | return record;
8 | }
9 |
10 | setupController(controller, model) {
11 | super.setupController(controller, model);
12 | controller.set(
13 | "allEmbeddings",
14 | this.modelFor("adminPlugins.show.discourse-ai-embeddings")
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/admin/adapters/ai-feature.js:
--------------------------------------------------------------------------------
1 | import RestAdapter from "discourse/adapters/rest";
2 |
3 | export default class AiFeatureAdapter extends RestAdapter {
4 | jsonMode = true;
5 |
6 | basePath() {
7 | return "/admin/plugins/discourse-ai/";
8 | }
9 |
10 | pathFor(store, type, findArgs) {
11 | // removes underscores which are implemented in base
12 | let path =
13 | this.basePath(store, type, findArgs) +
14 | store.pluralize(this.apiNameFor(type));
15 | return this.appendQueryParams(path, findArgs);
16 | }
17 |
18 | apiNameFor() {
19 | return "ai-feature";
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/db/migrate/20250508183456_create_inferred_concept_topics.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateInferredConceptTopics < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :inferred_concept_topics, id: false do |t|
6 | t.bigint :inferred_concept_id
7 | t.bigint :topic_id
8 | t.timestamps
9 | end
10 |
11 | add_index :inferred_concept_topics,
12 | %i[topic_id inferred_concept_id],
13 | unique: true,
14 | name: "index_inferred_concept_topics_uniqueness"
15 |
16 | add_index :inferred_concept_topics, :inferred_concept_id
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/routes/discourse-ai-shared-conversation-show.js:
--------------------------------------------------------------------------------
1 | import { service } from "@ember/service";
2 | import DiscourseRoute from "discourse/routes/discourse";
3 |
4 | export default class DiscourseAiSharedConversationShowRoute extends DiscourseRoute {
5 | @service currentUser;
6 |
7 | beforeModel(transition) {
8 | if (this.currentUser?.user_option?.external_links_in_new_tab) {
9 | window.open(transition.intent.url, "_blank");
10 | } else {
11 | this.redirect(transition.intent.url);
12 | }
13 | transition.abort();
14 | }
15 |
16 | redirect(url) {
17 | window.location = url;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/config/locales/client.en_GB.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | en_GB:
8 | js:
9 | discourse_automation:
10 | scriptables:
11 | llm_report:
12 | fields:
13 | categories:
14 | label: "Categories"
15 | discourse_ai:
16 | usage:
17 | summary: "Summary"
18 | ai_persona:
19 | description: "Description"
20 | tools:
21 | description: "Description"
22 | summary: "Summary"
23 |
--------------------------------------------------------------------------------
/db/migrate/20250717075002_set_translation_backfill_max_age.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class SetTranslationBackfillMaxAge < ActiveRecord::Migration[7.2]
3 | def up
4 | execute <<~SQL
5 | UPDATE site_settings
6 | SET value = '20000'
7 | WHERE name = 'ai_translation_backfill_max_age_days'
8 | AND value::integer > 20000;
9 | SQL
10 |
11 | execute <<~SQL
12 | UPDATE site_settings
13 | SET value = '0'
14 | WHERE name = 'ai_translation_backfill_max_age_days'
15 | AND value::integer < 0;
16 | SQL
17 | end
18 |
19 | def down
20 | raise ActiveRecord::IrreversibleMigration
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/app/models/post_custom_prompt.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class PostCustomPrompt < ActiveRecord::Base
4 | belongs_to :post
5 | end
6 |
7 | class ::Post
8 | has_one :post_custom_prompt, dependent: :destroy
9 | end
10 |
11 | # == Schema Information
12 | #
13 | # Table name: post_custom_prompts
14 | #
15 | # id :bigint not null, primary key
16 | # post_id :integer not null
17 | # custom_prompt :json not null
18 | # created_at :datetime not null
19 | # updated_at :datetime not null
20 | #
21 | # Indexes
22 | #
23 | # index_post_custom_prompts_on_post_id (post_id) UNIQUE
24 | #
25 |
--------------------------------------------------------------------------------
/db/migrate/20240618080148_create_ai_tools.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateAiTools < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :ai_tools do |t|
6 | t.string :name, null: false, max_length: 100, unique: true
7 | t.string :description, null: false, max_length: 1000
8 |
9 | t.string :summary, null: false, max_length: 255
10 |
11 | t.jsonb :parameters, null: false, default: {}
12 | t.text :script, null: false, max_length: 100_000
13 | t.integer :created_by_id, null: false
14 |
15 | t.boolean :enabled, null: false, default: true
16 | t.timestamps
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/db/migrate/20241125132452_unique_ai_summaries.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class UniqueAiSummaries < ActiveRecord::Migration[7.1]
3 | def up
4 | execute <<~SQL
5 | DELETE FROM ai_summaries ais1
6 | USING ai_summaries ais2
7 | WHERE ais1.id < ais2.id
8 | AND ais1.target_id = ais2.target_id
9 | AND ais1.target_type = ais2.target_type
10 | AND ais1.summary_type = ais2.summary_type
11 | SQL
12 |
13 | add_index :ai_summaries, %i[target_id target_type summary_type], unique: true
14 | end
15 |
16 | def down
17 | remove_index :ai_summaries, column: %i[target_id target_type summary_type]
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/app/models/chat_message_custom_prompt.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ChatMessageCustomPrompt < ActiveRecord::Base
4 | # belongs_to chat message but going to avoid the cross dependency for now
5 | end
6 |
7 | # == Schema Information
8 | #
9 | # Table name: chat_message_custom_prompts
10 | #
11 | # id :bigint not null, primary key
12 | # message_id :bigint not null
13 | # custom_prompt :json not null
14 | # created_at :datetime not null
15 | # updated_at :datetime not null
16 | #
17 | # Indexes
18 | #
19 | # index_chat_message_custom_prompts_on_message_id (message_id) UNIQUE
20 | #
21 |
--------------------------------------------------------------------------------
/db/migrate/20250429060311_move_dall_e_url.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class MoveDallEUrl < ActiveRecord::Migration[7.2]
3 | def up
4 | execute <<~SQL
5 | UPDATE site_settings
6 | SET name = 'ai_openai_image_generation_url'
7 | WHERE name = 'ai_openai_dall_e_3_url'
8 | AND NOT EXISTS (
9 | SELECT 1
10 | FROM site_settings
11 | WHERE name = 'ai_openai_image_generation_url')
12 | SQL
13 |
14 | execute <<~SQL
15 | DELETE FROM site_settings
16 | WHERE name = 'ai_openai_dall_e_3_url'
17 | SQL
18 | end
19 |
20 | def down
21 | raise ActiveRecord::IrreversibleMigration
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/spec/lib/personas/settings_explorer_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Personas::SettingsExplorer do
4 | let(:settings_explorer) { subject }
5 |
6 | before { enable_current_plugin }
7 |
8 | it "renders schema" do
9 | prompt = settings_explorer.system_prompt
10 |
11 | # check we do not render plugin settings
12 | expect(prompt).not_to include("ai_bot_enabled_personas")
13 |
14 | expect(prompt).to include("site_description")
15 |
16 | expect(settings_explorer.tools).to eq(
17 | [DiscourseAi::Personas::Tools::SettingContext, DiscourseAi::Personas::Tools::SearchSettings],
18 | )
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { LinkTo } from "@ember/routing";
3 | import { i18n } from "discourse-i18n";
4 |
5 | export default class AISentimentDashboard extends Component {
6 | static shouldRender(_outletArgs, helper) {
7 | return helper.siteSettings.ai_sentiment_enabled;
8 | }
9 |
10 |
11 |
12 |
13 | {{i18n "discourse_ai.sentiments.dashboard.title"}}
14 |
15 |
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/config/locales/server.et.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | et:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Date"
11 | emotion_neutral:
12 | title: "\U0001F610 Neutraalne"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "Otsi"
17 | time: "Aeg"
18 | sentiment:
19 | reports:
20 | post_emotion:
21 | neutral: "Neutraalne \U0001F610"
22 | sentiment_analysis:
23 | neutral: "Neutraalne"
24 |
--------------------------------------------------------------------------------
/db/migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CopySummarySectionsToAiSummaries < ActiveRecord::Migration[7.0]
4 | def up
5 | execute <<-SQL
6 | INSERT INTO ai_summaries (id, target_id, target_type, content_range, summarized_text, original_content_sha, algorithm, created_at, updated_at)
7 | SELECT id, target_id, target_type, content_range, summarized_text, original_content_sha, algorithm, created_at, updated_at
8 | FROM summary_sections
9 | WHERE meta_section_id IS NULL
10 | SQL
11 | end
12 |
13 | def down
14 | raise ActiveRecord::IrreversibleMigration
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/configuration/llm_vision_enumerator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "enum_site_setting"
4 |
5 | module DiscourseAi
6 | module Configuration
7 | class LlmVisionEnumerator < ::EnumSiteSetting
8 | def self.valid_value?(val)
9 | true
10 | end
11 |
12 | def self.values
13 | values = DB.query_hash(<<~SQL).map(&:symbolize_keys)
14 | SELECT display_name AS name, id AS value
15 | FROM llm_models
16 | WHERE vision_enabled
17 | SQL
18 |
19 | values.each { |value_h| value_h[:value] = "custom:#{value_h[:value]}" }
20 |
21 | values
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/split-new-topic-title-after/ai-title-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester";
3 | import { showPostAIHelper } from "../../lib/show-ai-helper";
4 |
5 | export default class AiTitleSuggestion extends Component {
6 | static shouldRender(outletArgs, helper) {
7 | return showPostAIHelper(outletArgs, helper);
8 | }
9 |
10 |
11 |
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/db/migrate/20241020010245_add_tool_name_to_ai_tools.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddToolNameToAiTools < ActiveRecord::Migration[7.1]
4 | def up
5 | add_column :ai_tools,
6 | :tool_name,
7 | :string,
8 | null: false,
9 | limit: 100,
10 | default: "",
11 | if_not_exists: true
12 |
13 | # Migrate existing name to tool_name
14 | execute <<~SQL
15 | UPDATE ai_tools
16 | SET tool_name = regexp_replace(LOWER(name),'[^a-z0-9_]','', 'g');
17 | SQL
18 | end
19 |
20 | def down
21 | remove_column :ai_tools, :tool_name, if_exists: true
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import AiTagSuggester from "../../components/suggestion-menus/ai-tag-suggester";
3 | import { showComposerAiHelper } from "../../lib/show-ai-helper";
4 |
5 | export default class AiTagSuggestion extends Component {
6 | static shouldRender(outletArgs, helper) {
7 | return showComposerAiHelper(
8 | outletArgs?.composer,
9 | helper.siteSettings,
10 | helper.currentUser,
11 | "suggestions"
12 | );
13 | }
14 |
15 |
16 |
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-search.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import AiFullPageSearch from "../../components/ai-full-page-search";
3 |
4 | export default class AiFullPageSearchConnector extends Component {
5 | static shouldRender(_args, { siteSettings }) {
6 | return siteSettings.ai_embeddings_semantic_search_enabled;
7 | }
8 |
9 |
10 |
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/spec/support/stable_diffusion_stubs.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class StableDiffusionStubs
4 | include RSpec::Matchers
5 |
6 | def stub_response(prompt, images)
7 | artifacts = images.map { |i| { base64: i } }
8 |
9 | WebMock
10 | .stub_request(
11 | :post,
12 | "https://api.stability.dev/v1/generation/#{SiteSetting.ai_stability_engine}/text-to-image",
13 | )
14 | .with do |request|
15 | json = JSON.parse(request.body, symbolize_names: true)
16 | expect(json[:text_prompts][0][:text]).to eq(prompt)
17 | true
18 | end
19 | .to_return(status: 200, body: { artifacts: artifacts }.to_json)
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/edit-topic-tags__after/ai-tag-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import AiTagSuggester from "../../components/suggestion-menus/ai-tag-suggester";
3 | import { showComposerAiHelper } from "../../lib/show-ai-helper";
4 |
5 | export default class AiCategorySuggestion extends Component {
6 | static shouldRender(outletArgs, helper) {
7 | return showComposerAiHelper(
8 | outletArgs?.composer,
9 | helper.siteSettings,
10 | helper.currentUser,
11 | "suggestions"
12 | );
13 | }
14 |
15 |
16 |
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/edit-topic-title__after/ai-title-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import AiTitleSuggester from "../../components/suggestion-menus/ai-title-suggester";
3 | import { showComposerAiHelper } from "../../lib/show-ai-helper";
4 |
5 | export default class AiTitleSuggestion extends Component {
6 | static shouldRender(outletArgs, helper) {
7 | return showComposerAiHelper(
8 | outletArgs?.composer,
9 | helper.siteSettings,
10 | helper.currentUser,
11 | "suggestions"
12 | );
13 | }
14 |
15 |
16 |
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-edit.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class AdminPluginsShowDiscourseAiPersonasEdit extends DiscourseRoute {
4 | async model(params) {
5 | const allPersonas = this.modelFor(
6 | "adminPlugins.show.discourse-ai-personas"
7 | );
8 | const id = parseInt(params.id, 10);
9 | return allPersonas.findBy("id", id);
10 | }
11 |
12 | setupController(controller, model) {
13 | super.setupController(controller, model);
14 | controller.set(
15 | "allPersonas",
16 | this.modelFor("adminPlugins.show.discourse-ai-personas")
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/services/gists.js:
--------------------------------------------------------------------------------
1 | import { tracked } from "@glimmer/tracking";
2 | import Service, { service } from "@ember/service";
3 |
4 | export default class Gists extends Service {
5 | @service router;
6 |
7 | @tracked preference = localStorage.getItem("topicListLayout");
8 |
9 | get shouldShow() {
10 | return this.router.currentRoute.attributes?.list?.topics?.some(
11 | (topic) => topic.ai_topic_gist
12 | );
13 | }
14 |
15 | setPreference(value) {
16 | this.preference = value;
17 | localStorage.setItem("topicListLayout", value);
18 |
19 | if (this.preference === "table-ai") {
20 | localStorage.setItem("aiPreferred", true);
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/db/migrate/20250607071239_create_ai_artifacts_key_values.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class CreateAiArtifactsKeyValues < ActiveRecord::Migration[7.2]
3 | def change
4 | create_table :ai_artifact_key_values do |t|
5 | t.bigint :ai_artifact_id, null: false
6 | t.integer :user_id, null: false
7 | t.string :key, null: false, limit: 50
8 | t.string :value, null: false, limit: 20_000
9 | t.boolean :public, null: false, default: false
10 | t.timestamps
11 | end
12 |
13 | add_index :ai_artifact_key_values,
14 | %i[ai_artifact_id user_id key],
15 | unique: true,
16 | name: "index_ai_artifact_kv_unique"
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/serializers/ai_chat_channel_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiChatChannelSerializer < ApplicationSerializer
4 | attributes :id, :chatable, :chatable_type, :chatable_url, :slug
5 |
6 | def chatable
7 | case object.chatable_type
8 | when "Category"
9 | BasicCategorySerializer.new(object.chatable, root: false).as_json
10 | when "DirectMessage"
11 | Chat::DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json
12 | when "Site"
13 | nil
14 | end
15 | end
16 |
17 | def title
18 | # Display all participants for a DM.
19 | # For category channels, the argument is ignored.
20 | object.title(nil)
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/app/serializers/ai_custom_tool_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiCustomToolSerializer < ApplicationSerializer
4 | attributes :id,
5 | :name,
6 | :tool_name,
7 | :description,
8 | :summary,
9 | :parameters,
10 | :script,
11 | :rag_chunk_tokens,
12 | :rag_chunk_overlap_tokens,
13 | :rag_llm_model_id,
14 | :created_by_id,
15 | :created_at,
16 | :updated_at
17 |
18 | self.root = "ai_tool"
19 |
20 | has_many :rag_uploads, serializer: UploadSerializer, embed: :object
21 |
22 | def rag_uploads
23 | object.uploads
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/user-preferences-nav/ai-preferences.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { LinkTo } from "@ember/routing";
3 | import dIcon from "discourse/helpers/d-icon";
4 | import { i18n } from "discourse-i18n";
5 |
6 | export default class AutoImageCaptionSetting extends Component {
7 | static shouldRender(outletArgs, helper) {
8 | return helper.siteSettings.discourse_ai_enabled;
9 | }
10 |
11 |
12 |
13 |
14 | {{dIcon "discourse-sparkles"}}
15 | {{i18n "discourse_ai.title"}}
16 |
17 |
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/spec/fabricators/classification_result_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Fabricator(:classification_result) do
4 | target { Fabricate(:post) }
5 | classification_type "sentiment"
6 | end
7 |
8 | Fabricator(:sentiment_classification, from: :classification_result) do
9 | model_used "cardiffnlp/twitter-roberta-base-sentiment-latest"
10 | classification { { negative: 0.72, neutral: 0.23, positive: 0.4 } }
11 | end
12 |
13 | Fabricator(:emotion_classification, from: :classification_result) do
14 | model_used "j-hartmann/emotion-english-distilroberta-base"
15 | classification do
16 | { sadness: 0.72, surprise: 0.23, fear: 0.4, anger: 0.87, joy: 0.22, disgust: 0.70 }
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/spec/system/page_objects/modals/ai_tool_test_modal.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module PageObjects
4 | module Modals
5 | class AiToolTest < PageObjects::Modals::Base
6 | BODY_SELECTOR = ".ai-tool-test-modal__body"
7 | MODAL_SELECTOR = ".ai-tool-test-modal"
8 |
9 | def base_currency=(value)
10 | body.fill_in("base_currency", with: value)
11 | end
12 |
13 | def target_currency=(value)
14 | body.fill_in("target_currency", with: value)
15 | end
16 |
17 | def amount=(value)
18 | body.fill_in("amount", with: value)
19 | end
20 |
21 | def run_test
22 | click_primary_button
23 | end
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/db/migrate/20230224165056_create_classification_results_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class CreateClassificationResultsTable < ActiveRecord::Migration[7.0]
3 | def change
4 | create_table :classification_results do |t|
5 | t.string :model_used, null: true
6 | t.string :classification_type, null: true
7 | t.integer :target_id, null: true
8 | t.string :target_type, null: true
9 |
10 | t.jsonb :classification, null: true
11 | t.timestamps
12 | end
13 |
14 | add_index :classification_results,
15 | %i[target_id target_type model_used],
16 | unique: true,
17 | name: "unique_classification_target_per_type"
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/evals/run:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require_relative "lib/boot"
5 | require_relative "lib/llm"
6 | require_relative "lib/cli"
7 | require_relative "lib/runner"
8 | require_relative "lib/eval"
9 | require_relative "lib/prompts/prompt_evaluator"
10 | require_relative "lib/prompts/single_test_runner"
11 |
12 | options = DiscourseAi::Evals::Cli.parse_options!
13 |
14 | if options.list
15 | DiscourseAi::Evals::Runner.print
16 | exit 0
17 | end
18 |
19 | if options.list_models
20 | DiscourseAi::Evals::Llm.print
21 | exit 0
22 | end
23 |
24 | DiscourseAi::Evals::Runner.new(
25 | eval_name: options.eval_name,
26 | llms: DiscourseAi::Evals::Llm.choose(options.model),
27 | ).run!
28 |
--------------------------------------------------------------------------------
/db/migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CopySummarizationStrategyToAiSummarizationStrategy < ActiveRecord::Migration[7.0]
4 | def up
5 | execute <<-SQL
6 | UPDATE site_settings
7 | SET data_type = (SELECT data_type FROM site_settings WHERE name = 'summarization_strategy'),
8 | value = (SELECT value FROM site_settings WHERE name = 'summarization_strategy')
9 | WHERE name = 'ai_summarization_strategy'
10 | AND EXISTS (SELECT 1 FROM site_settings WHERE name = 'summarization_strategy');
11 | SQL
12 | end
13 |
14 | def down
15 | raise ActiveRecord::IrreversibleMigration
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/db/migrate/20250121162520_configurable_embeddings_prefixes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class ConfigurableEmbeddingsPrefixes < ActiveRecord::Migration[7.2]
3 | def up
4 | add_column :embedding_definitions, :embed_prompt, :string, null: false, default: ""
5 | add_column :embedding_definitions, :search_prompt, :string, null: false, default: ""
6 |
7 | # 4 is bge-large-en. Default model and the only one using this so far.
8 | execute <<~SQL
9 | UPDATE embedding_definitions
10 | SET search_prompt='Represent this sentence for searching relevant passages:'
11 | WHERE id = 4
12 | SQL
13 | end
14 |
15 | def down
16 | raise ActiveRecord::IrreversibleMigration
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/config/locales/server.ca.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | ca:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Data"
11 | emotion_neutral:
12 | title: "\U0001F610 Neutre"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "Cerca"
17 | time: "Hora"
18 | sentiment:
19 | reports:
20 | post_emotion:
21 | neutral: "Neutre \U0001F610"
22 | sentiment_analysis:
23 | neutral: "Neutre"
24 | ai_staff_action_logger:
25 | updated: "actualitzat"
26 |
--------------------------------------------------------------------------------
/db/migrate/20231109011155_create_ai_personas.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | #
3 | class CreateAiPersonas < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :ai_personas do |t|
6 | t.string :name, null: false, unique: true, limit: 100
7 | t.string :description, null: false, limit: 2000
8 | t.string :commands, array: true, default: [], null: false
9 | t.string :system_prompt, null: false, limit: 10_000_000
10 | t.integer :allowed_group_ids, array: true, default: [], null: false
11 | t.integer :created_by_id
12 | t.boolean :enabled, default: true, null: false
13 | t.timestamps
14 | end
15 |
16 | add_index :ai_personas, :name, unique: true
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/jobs/regular/generate_chat_thread_title.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class GenerateChatThreadTitle < ::Jobs::Base
5 | sidekiq_options queue: "low"
6 |
7 | def execute(args)
8 | return unless SiteSetting.ai_helper_automatic_chat_thread_title
9 | return if (thread_id = args[:thread_id]).blank?
10 |
11 | thread = ::Chat::Thread.find_by_id(thread_id)
12 | return if thread.nil? || thread.title.present?
13 |
14 | title = DiscourseAi::AiHelper::ChatThreadTitler.new(thread).suggested_title
15 | return if title.blank?
16 |
17 | # TODO use a proper API that will make the new title update live
18 | thread.update!(title: title)
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/db/migrate/20240609061418_tool_details_and_command_removal.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ToolDetailsAndCommandRemoval < ActiveRecord::Migration[7.0]
4 | def change
5 | add_column :ai_personas, :tool_details, :boolean, default: true, null: false
6 | add_column :ai_personas, :tools, :json, null: false, default: []
7 | # we can not do this cause we are seeding the data and in certain
8 | # build scenarios we seed prior to running post migrations
9 | # this risks potentially dropping data but the window is small
10 | # Migration::ColumnDropper.mark_readonly(:ai_personas, :commands)
11 |
12 | execute <<~SQL
13 | UPDATE ai_personas
14 | SET tools = commands
15 | SQL
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/models/inferred_concept.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class InferredConcept < ActiveRecord::Base
4 | has_many :inferred_concept_topics
5 | has_many :topics, through: :inferred_concept_topics
6 |
7 | has_many :inferred_concept_posts
8 | has_many :posts, through: :inferred_concept_posts
9 |
10 | validates :name, presence: true, uniqueness: true
11 | end
12 |
13 | # == Schema Information
14 | #
15 | # Table name: inferred_concepts
16 | #
17 | # id :bigint not null, primary key
18 | # name :string not null
19 | # created_at :datetime not null
20 | # updated_at :datetime not null
21 | #
22 | # Indexes
23 | #
24 | # index_inferred_concepts_on_name (name) UNIQUE
25 | #
26 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester";
3 | import { showComposerAiHelper } from "../../lib/show-ai-helper";
4 |
5 | export default class AiCategorySuggestion extends Component {
6 | static shouldRender(outletArgs, helper) {
7 | return showComposerAiHelper(
8 | outletArgs?.composer,
9 | helper.siteSettings,
10 | helper.currentUser,
11 | "suggestions"
12 | );
13 | }
14 |
15 |
16 |
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/config/locales/server.gl.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | gl:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Data"
11 | emotion_neutral:
12 | title: "\U0001F610 Neutro"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "Buscar"
17 | time: "Hora"
18 | sentiment:
19 | reports:
20 | post_emotion:
21 | neutral: "Neutro \U0001F610"
22 | sentiment_analysis:
23 | neutral: "Neutro"
24 | ai_staff_action_logger:
25 | updated: "actualizado"
26 | removed: "retirado"
27 |
--------------------------------------------------------------------------------
/app/jobs/regular/fast_track_topic_gist.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::Jobs
4 | class FastTrackTopicGist < ::Jobs::Base
5 | sidekiq_options retry: false
6 |
7 | def execute(args)
8 | return if !SiteSetting.discourse_ai_enabled
9 | return if !SiteSetting.ai_summarization_enabled
10 | return if !SiteSetting.ai_summary_gists_enabled
11 |
12 | topic = Topic.find_by(id: args[:topic_id])
13 | return if topic.blank?
14 |
15 | summarizer = DiscourseAi::Summarization.topic_gist(topic)
16 | gist = summarizer.existing_summary
17 | return if gist.present? && (!gist.outdated || gist.created_at >= 5.minutes.ago)
18 |
19 | summarizer.summarize(Discourse.system_user)
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/composer-after-save-or-cancel/ai-image-caption-loader.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { service } from "@ember/service";
3 | import loadingSpinner from "discourse/helpers/loading-spinner";
4 | import { i18n } from "discourse-i18n";
5 |
6 | export default class AiImageCaptionLoader extends Component {
7 | @service imageCaptionPopup;
8 |
9 |
10 | {{#if this.imageCaptionPopup.showAutoCaptionLoader}}
11 |
12 | {{loadingSpinner size="small"}}
13 | {{i18n
14 | "discourse_ai.ai_helper.image_caption.automatic_caption_loading"
15 | }}
16 |
17 | {{/if}}
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/assets/stylesheets/modules/summarization/desktop/ai-summary.scss:
--------------------------------------------------------------------------------
1 | html.scrollable-modal {
2 | overflow: auto; // overrides core .modal-open class scroll lock
3 | }
4 |
5 | .ai-summary-modal {
6 | .d-modal__container {
7 | position: fixed;
8 | top: var(--header-offset);
9 | margin-top: 1em;
10 | right: 1em;
11 | width: 100vw;
12 | max-width: 30em;
13 | max-height: calc(
14 | 100vh - var(--header-offset) - 3rem - var(--composer-height, 0px)
15 | );
16 | box-shadow: var(--shadow-menu-panel);
17 | }
18 |
19 | .fullscreen-composer & {
20 | display: none;
21 | }
22 | }
23 |
24 | .ai-summary-modal + .d-modal__backdrop {
25 | background: transparent; // allows for reading, but still triggers clickoutside event
26 | }
27 |
--------------------------------------------------------------------------------
/config/locales/server.da.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | da:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Dato"
11 | emotion_neutral:
12 | title: "\U0001F610 Neutral"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "Søg"
17 | time: "Tidspunkt"
18 | sentiment:
19 | reports:
20 | post_emotion:
21 | neutral: "Neutral \U0001F610"
22 | sentiment_analysis:
23 | neutral: "Neutral"
24 | ai_staff_action_logger:
25 | updated: "opdateret"
26 | removed: "fjernet"
27 |
--------------------------------------------------------------------------------
/config/locales/server.ko.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | ko:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "날짜"
11 | emotion_neutral:
12 | title: "\U0001F610 중립국"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "검색"
17 | time: "시간"
18 | summarize: "요약하기"
19 | sentiment:
20 | reports:
21 | post_emotion:
22 | neutral: "중립국 \U0001F610"
23 | sentiment_analysis:
24 | neutral: "중립국"
25 | ai_staff_action_logger:
26 | updated: "업데이트"
27 | removed: "제거됨"
28 |
--------------------------------------------------------------------------------
/config/locales/server.zh_TW.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | zh_TW:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "日期"
11 | emotion_neutral:
12 | title: "\U0001F610 中性"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "搜尋"
17 | time: "時間"
18 | summarize: "總結"
19 | sentiment:
20 | reports:
21 | post_emotion:
22 | neutral: "中性 \U0001F610"
23 | sentiment_analysis:
24 | neutral: "中性"
25 | ai_staff_action_logger:
26 | updated: "更新時間"
27 | removed: "已移除"
28 |
--------------------------------------------------------------------------------
/db/migrate/20241217164540_create_embedding_definitions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class CreateEmbeddingDefinitions < ActiveRecord::Migration[7.2]
3 | def change
4 | create_table :embedding_definitions do |t|
5 | t.string :display_name, null: false
6 | t.integer :dimensions, null: false
7 | t.integer :max_sequence_length, null: false
8 | t.integer :version, null: false, default: 1
9 | t.string :pg_function, null: false
10 | t.string :provider, null: false
11 | t.string :tokenizer_class, null: false
12 | t.string :url, null: false
13 | t.string :api_key
14 | t.boolean :seeded, null: false, default: false
15 | t.jsonb :provider_params
16 | t.timestamps
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/config/locales/server.hy.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | hy:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Ամսաթիվ"
11 | emotion_neutral:
12 | title: "\U0001F610 Նեյտրալ"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "Որոնում"
17 | time: "Ժամ"
18 | sentiment:
19 | reports:
20 | post_emotion:
21 | neutral: "Նեյտրալ \U0001F610"
22 | sentiment_analysis:
23 | neutral: "Նեյտրալ"
24 | ai_staff_action_logger:
25 | updated: "թարմացված"
26 | removed: "հեռացվել է"
27 |
--------------------------------------------------------------------------------
/config/locales/server.te.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | te:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "తేదీ"
11 | emotion_neutral:
12 | title: "\U0001F610 తటస్థ"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "శోధించండి"
17 | time: "కాలం"
18 | sentiment:
19 | reports:
20 | post_emotion:
21 | neutral: "తటస్థ \U0001F610"
22 | sentiment_analysis:
23 | neutral: "తటస్థ"
24 | ai_staff_action_logger:
25 | updated: "నవీకరించబడింది"
26 | removed: "తీసివేయబడింది"
27 |
--------------------------------------------------------------------------------
/db/migrate/20240704020102_reset_identity_on_ai_summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class ResetIdentityOnAiSummary < ActiveRecord::Migration[7.0]
3 | def up
4 | add_index :ai_summaries, %i[target_type target_id]
5 |
6 | # we need to reset identity since we moved this from the old summary_sections table
7 | execute <<-SQL
8 | DO $$
9 | DECLARE
10 | max_id integer;
11 | BEGIN
12 | SELECT MAX(id) INTO max_id FROM ai_summaries;
13 | IF max_id IS NOT NULL THEN
14 | PERFORM setval(pg_get_serial_sequence('ai_summaries', 'id'), max_id);
15 | END IF;
16 | END $$
17 | SQL
18 | end
19 |
20 | def down
21 | remove_index :ai_summaries, %i[target_type target_id]
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/db/migrate/20250210032345_migrate_persona_to_llm_model_id.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class MigratePersonaToLlmModelId < ActiveRecord::Migration[7.2]
3 | def up
4 | add_column :ai_personas, :default_llm_id, :bigint
5 | add_column :ai_personas, :question_consolidator_llm_id, :bigint
6 | # personas are seeded, we do not mark stuff as readonline
7 |
8 | execute <<~SQL
9 | UPDATE ai_personas
10 | set
11 | default_llm_id = (select id from llm_models where ('custom:' || id) = default_llm),
12 | question_consolidator_llm_id = (select id from llm_models where ('custom:' || id) = question_consolidator_llm)
13 | SQL
14 | end
15 |
16 | def down
17 | raise ActiveRecord::IrreversibleMigration
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/db/migrate/20250122131007_matryoshka_dimensions_support.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class MatryoshkaDimensionsSupport < ActiveRecord::Migration[7.2]
3 | def change
4 | add_column :embedding_definitions, :matryoshka_dimensions, :boolean, null: false, default: false
5 |
6 | execute <<~SQL
7 | UPDATE embedding_definitions
8 | SET matryoshka_dimensions = TRUE
9 | WHERE
10 | provider = 'open_ai' AND
11 | provider_params IS NOT NULL AND
12 | (
13 | (provider_params->>'model_name') = 'text-embedding-3-large' OR
14 | (provider_params->>'model_name') = 'text-embedding-3-small'
15 | )
16 | SQL
17 | end
18 |
19 | def down
20 | raise ActiveRecord::IrreversibleMigration
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/spec/system/admin_dashboard_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe "Admin dashboard", type: :system do
4 | fab!(:admin)
5 |
6 | before { enable_current_plugin }
7 |
8 | xit "displays the sentiment dashboard" do
9 | SiteSetting.ai_sentiment_enabled = true
10 | sign_in(admin)
11 |
12 | visit "/admin"
13 | find(".navigation-item.sentiment").click()
14 |
15 | expect(page).to have_css(".section.sentiment")
16 | end
17 |
18 | xit "displays the emotion table with links" do
19 | SiteSetting.ai_sentiment_enabled = true
20 | sign_in(admin)
21 |
22 | visit "/admin"
23 | find(".navigation-item.sentiment").click()
24 |
25 | expect(page).to have_css(".admin-report.emotion-love .cell.value.today-count a")
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import AiTitleSuggester from "../../components/suggestion-menus/ai-title-suggester";
3 | import { showComposerAiHelper } from "../../lib/show-ai-helper";
4 |
5 | export default class AiTitleSuggestion extends Component {
6 | static shouldRender(outletArgs, helper) {
7 | return showComposerAiHelper(
8 | outletArgs?.composer,
9 | helper.siteSettings,
10 | helper.currentUser,
11 | "suggestions"
12 | );
13 | }
14 |
15 |
16 | {{#unless @outletArgs.composer.disableTitleInput}}
17 |
18 | {{/unless}}
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/config/locales/server.ur.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | ur:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "تاریخ"
11 | emotion_neutral:
12 | title: "\U0001F610 نیوٹرل"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "تلاش کریں"
17 | time: "وقت"
18 | summarize: "خلاصہ"
19 | sentiment:
20 | reports:
21 | post_emotion:
22 | neutral: "نیوٹرل \U0001F610"
23 | sentiment_analysis:
24 | neutral: "نیوٹرل"
25 | ai_staff_action_logger:
26 | updated: "اپ ڈیٹ"
27 | removed: "ہٹا دیا"
28 |
--------------------------------------------------------------------------------
/lib/automation/llm_persona_triage.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module DiscourseAi
3 | module Automation
4 | module LlmPersonaTriage
5 | def self.handle(post:, persona_id:, whisper: false, silent_mode: false, automation: nil)
6 | DiscourseAi::AiBot::Playground.reply_to_post(
7 | post: post,
8 | persona_id: persona_id,
9 | whisper: whisper,
10 | silent_mode: silent_mode,
11 | feature_name: "automation - #{automation&.name}",
12 | )
13 | rescue => e
14 | Discourse.warn_exception(
15 | e,
16 | message: "Error responding to: #{post&.url} in LlmPersonaTriage.handle",
17 | )
18 | raise e if Rails.env.test?
19 | nil
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/sentiment/emotions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Sentiment
5 | class Emotions
6 | LIST = %w[
7 | admiration
8 | amusement
9 | anger
10 | annoyance
11 | approval
12 | caring
13 | confusion
14 | curiosity
15 | desire
16 | disappointment
17 | disapproval
18 | disgust
19 | embarrassment
20 | excitement
21 | fear
22 | gratitude
23 | grief
24 | joy
25 | love
26 | nervousness
27 | neutral
28 | optimism
29 | pride
30 | realization
31 | relief
32 | remorse
33 | sadness
34 | surprise
35 | ]
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/app/serializers/ai_sentiment_post_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiSentimentPostSerializer < ApplicationSerializer
4 | attributes :post_id,
5 | :topic_id,
6 | :topic_title,
7 | :post_number,
8 | :username,
9 | :name,
10 | :avatar_template,
11 | :excerpt,
12 | :sentiment,
13 | :truncated,
14 | :category_id,
15 | :created_at
16 |
17 | def avatar_template
18 | User.avatar_template(object.username, object.uploaded_avatar_id)
19 | end
20 |
21 | def excerpt
22 | Post.excerpt(object.post_cooked)
23 | end
24 |
25 | def truncated
26 | object.post_cooked.length > SiteSetting.post_excerpt_maxlength
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/spec/lib/personas/tools/list_categories_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Personas::Tools::ListCategories do
4 | fab!(:llm_model)
5 | let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
6 | let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }
7 |
8 | before do
9 | enable_current_plugin
10 | SiteSetting.ai_bot_enabled = true
11 | end
12 |
13 | describe "#process" do
14 | it "list available categories" do
15 | Fabricate(:category, name: "america", posts_year: 999)
16 |
17 | info = described_class.new({}, bot_user: bot_user, llm: llm).invoke.to_s
18 |
19 | expect(info).to include("america")
20 | expect(info).to include("999")
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-new.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class AdminPluginsShowDiscourseAiLlmsNew extends DiscourseRoute {
4 | queryParams = {
5 | llmTemplate: { refreshModel: true },
6 | };
7 |
8 | async model() {
9 | const record = this.store.createRecord("ai-llm");
10 | record.provider_params = {};
11 | return record;
12 | }
13 |
14 | setupController(controller, model) {
15 | super.setupController(controller, model);
16 | controller.set(
17 | "allLlms",
18 | this.modelFor("adminPlugins.show.discourse-ai-llms")
19 | );
20 | controller.set(
21 | "llmTemplate",
22 | this.paramsFor(this.routeName).llmTemplate || null
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/db/post_migrate/20240912055831_drop_persona_id_from_rag_document_fragments.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class DropPersonaIdFromRagDocumentFragments < ActiveRecord::Migration[7.1]
3 | def change
4 | execute <<~SQL
5 | UPDATE rag_document_fragments
6 | SET
7 | target_type = 'AiPersona',
8 | target_id = ai_persona_id
9 | WHERE ai_persona_id IS NOT NULL
10 | SQL
11 |
12 | # unlikely but lets be safe
13 | execute <<~SQL
14 | DELETE FROM rag_document_fragments
15 | WHERE target_id IS NULL OR target_type IS NULL
16 | SQL
17 |
18 | remove_column :rag_document_fragments, :ai_persona_id
19 | change_column_null :rag_document_fragments, :target_id, false
20 | change_column_null :rag_document_fragments, :target_type, false
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-edit.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class AdminPluginsShowDiscourseAiEmbeddingsEdit extends DiscourseRoute {
4 | async model(params) {
5 | const allEmbeddings = this.modelFor(
6 | "adminPlugins.show.discourse-ai-embeddings"
7 | );
8 | const id = parseInt(params.id, 10);
9 | const record = allEmbeddings.findBy("id", id);
10 | record.provider_params = record.provider_params || {};
11 | return record;
12 | }
13 |
14 | setupController(controller, model) {
15 | super.setupController(controller, model);
16 | controller.set(
17 | "allEmbeddings",
18 | this.modelFor("adminPlugins.show.discourse-ai-embeddings")
19 | );
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/db/migrate/20230406135943_add_provider_to_completion_prompts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AddProviderToCompletionPrompts < ActiveRecord::Migration[7.0]
4 | def up
5 | remove_index :completion_prompts, name: "index_completion_prompts_on_name"
6 | add_column :completion_prompts, :provider, :text
7 | add_index :completion_prompts, %i[name], unique: false
8 |
9 | # set provider for existing prompts
10 | DB.exec <<~SQL
11 | UPDATE completion_prompts
12 | SET provider = 'openai'
13 | WHERE provider IS NULL;
14 | SQL
15 | end
16 |
17 | def down
18 | remove_column :completion_prompts, :provider
19 | remove_index :completion_prompts, name: "index_completion_prompts_on_name"
20 | add_index :completion_prompts, %i[name], unique: true
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/personas/tools/option.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Personas
5 | module Tools
6 | class Option
7 | attr_reader :tool, :name, :type, :values, :default
8 |
9 | def initialize(tool:, name:, type:, values: nil, default: nil)
10 | @tool = tool
11 | @name = name.to_s
12 | @type = type
13 | @values = values
14 | @default = default
15 | end
16 |
17 | def localized_name
18 | I18n.t("discourse_ai.ai_bot.tool_options.#{tool.signature[:name]}.#{name}.name")
19 | end
20 |
21 | def localized_description
22 | I18n.t("discourse_ai.ai_bot.tool_options.#{tool.signature[:name]}.#{name}.description")
23 | end
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/spec/fabricators/ai_artifact_fabricator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | Fabricator(:ai_artifact) do
3 | user
4 | post
5 | name { sequence(:name) { |i| "artifact_#{i}" } }
6 | html { "Test Content
" }
7 | css { ".test { color: blue; }" }
8 | js { "console.log('test');" }
9 | metadata { { public: false } }
10 | end
11 |
12 | Fabricator(:ai_artifact_key_value) do
13 | ai_artifact
14 | user
15 | key { sequence(:key) { |i| "key_#{i}" } }
16 | value { "value" }
17 | public { false }
18 | end
19 |
20 | Fabricator(:ai_artifact_version) do
21 | ai_artifact
22 | version_number { sequence(:version_number) { |i| i } }
23 | html { "Version Content
" }
24 | css { ".version { color: red; }" }
25 | js { "console.log('version');" }
26 | change_description { "Test change" }
27 | end
28 |
--------------------------------------------------------------------------------
/spec/configuration/llm_validator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Configuration::LlmValidator do
4 | before { enable_current_plugin }
5 |
6 | describe "#valid_value?" do
7 | context "when the parent module is enabled and we try to reset the selected model" do
8 | before do
9 | assign_fake_provider_to(:ai_summarization_model)
10 | SiteSetting.ai_summarization_enabled = true
11 | end
12 |
13 | it "returns false and displays an error message" do
14 | validator = described_class.new(name: :ai_summarization_model)
15 |
16 | value = validator.valid_value?("")
17 |
18 | expect(value).to eq(false)
19 | expect(validator.error_message).to include("ai_summarization_enabled")
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/app/models/inferred_concept_post.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class InferredConceptPost < ActiveRecord::Base
4 | belongs_to :inferred_concept
5 | belongs_to :post
6 |
7 | validates :inferred_concept_id, presence: true
8 | validates :post_id, presence: true
9 | validates :inferred_concept_id, uniqueness: { scope: :post_id }
10 | end
11 |
12 | # == Schema Information
13 | #
14 | # Table name: inferred_concept_posts
15 | #
16 | # inferred_concept_id :bigint
17 | # post_id :bigint
18 | # created_at :datetime not null
19 | # updated_at :datetime not null
20 | #
21 | # Indexes
22 | #
23 | # index_inferred_concept_posts_on_inferred_concept_id (inferred_concept_id)
24 | # index_inferred_concept_posts_uniqueness (post_id,inferred_concept_id) UNIQUE
25 | #
26 |
--------------------------------------------------------------------------------
/db/migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CopyCustomSummarizationAllowedGroupsToAiCustomSummarizationAllowedGroups < ActiveRecord::Migration[
4 | 7.0
5 | ]
6 | def up
7 | execute <<-SQL
8 | UPDATE site_settings
9 | SET data_type = (SELECT data_type FROM site_settings WHERE name = 'custom_summarization_allowed_groups'),
10 | value = (SELECT value FROM site_settings WHERE name = 'custom_summarization_allowed_groups')
11 | WHERE name = 'ai_custom_summarization_allowed_groups'
12 | AND EXISTS (SELECT 1 FROM site_settings WHERE name = 'custom_summarization_allowed_groups');
13 | SQL
14 | end
15 |
16 | def down
17 | raise ActiveRecord::IrreversibleMigration
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/db/migrate/20241023041242_add_unique_constraint_to_ai_tools.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddUniqueConstraintToAiTools < ActiveRecord::Migration[7.1]
3 | def up
4 | # We need to remove duplicates before adding the unique constraint
5 | execute <<~SQL
6 | WITH duplicates AS (
7 | SELECT name, COUNT(*) as count, MIN(id) as keeper_id
8 | FROM ai_tools
9 | GROUP BY name
10 | HAVING COUNT(*) > 1
11 | )
12 | UPDATE ai_tools AS p
13 | SET name = CONCAT(p.name, p.id)
14 | FROM duplicates d
15 | WHERE p.name = d.name
16 | AND p.id != d.keeper_id;
17 | SQL
18 |
19 | add_index :ai_personas, :name, unique: true, if_not_exists: true
20 | end
21 |
22 | def down
23 | remove_index :ai_personas, :name, if_exists: true
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/models/inferred_concept_topic.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class InferredConceptTopic < ActiveRecord::Base
4 | belongs_to :inferred_concept
5 | belongs_to :topic
6 |
7 | validates :inferred_concept_id, presence: true
8 | validates :topic_id, presence: true
9 | validates :inferred_concept_id, uniqueness: { scope: :topic_id }
10 | end
11 |
12 | # == Schema Information
13 | #
14 | # Table name: inferred_concept_topics
15 | #
16 | # inferred_concept_id :bigint
17 | # topic_id :bigint
18 | # created_at :datetime not null
19 | # updated_at :datetime not null
20 | #
21 | # Indexes
22 | #
23 | # index_inferred_concept_topics_on_inferred_concept_id (inferred_concept_id)
24 | # index_inferred_concept_topics_uniqueness (topic_id,inferred_concept_id) UNIQUE
25 | #
26 |
--------------------------------------------------------------------------------
/db/migrate/20250114160500_backfill_rag_embeddings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class BackfillRagEmbeddings < ActiveRecord::Migration[7.2]
3 | def up
4 | if table_exists?(:ai_document_fragment_embeddings)
5 | not_backfilled =
6 | DB.query_single("SELECT COUNT(*) FROM ai_document_fragments_embeddings").first.to_i == 0
7 |
8 | if not_backfilled
9 | # Copy data from old tables to new tables
10 | execute <<~SQL
11 | INSERT INTO ai_document_fragments_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at)
12 | SELECT * FROM ai_document_fragment_embeddings;
13 | SQL
14 | end
15 | end
16 | end
17 |
18 | def down
19 | raise ActiveRecord::IrreversibleMigration
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/personas/settings_explorer.rb:
--------------------------------------------------------------------------------
1 | #frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Personas
5 | class SettingsExplorer < Persona
6 | def tools
7 | [Tools::SettingContext, Tools::SearchSettings]
8 | end
9 |
10 | def system_prompt
11 | <<~PROMPT
12 | You are Discourse Site settings bot.
13 |
14 | - You are able to find information about all the site settings.
15 | - You are able to request context for a specific setting.
16 | - You are a helpful teacher that teaches people about what each settings does.
17 | - Keep in mind that setting names are always a single word separated by underscores. eg. 'site_description'
18 |
19 | Current time is: {time}
20 | PROMPT
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/app/jobs/regular/stream_composer_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class StreamComposerHelper < ::Jobs::Base
5 | sidekiq_options retry: false
6 |
7 | def execute(args)
8 | return unless args[:prompt]
9 | return unless user = User.find_by(id: args[:user_id])
10 | return unless args[:text]
11 | return unless args[:client_id]
12 | return unless args[:progress_channel]
13 |
14 | helper_mode = args[:prompt]
15 |
16 | DiscourseAi::AiHelper::Assistant.new.stream_prompt(
17 | helper_mode,
18 | args[:text],
19 | user,
20 | args[:progress_channel],
21 | force_default_locale: args[:force_default_locale],
22 | client_id: args[:client_id],
23 | custom_prompt: args[:custom_prompt],
24 | )
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/app/models/classification_result.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ClassificationResult < ActiveRecord::Base
4 | belongs_to :target, polymorphic: true
5 |
6 | def self.has_sentiment_classification?
7 | where(classification_type: "sentiment").exists?
8 | end
9 | end
10 |
11 | # == Schema Information
12 | #
13 | # Table name: classification_results
14 | #
15 | # id :bigint not null, primary key
16 | # model_used :string
17 | # classification_type :string
18 | # target_id :bigint
19 | # target_type :string
20 | # classification :jsonb
21 | # created_at :datetime not null
22 | # updated_at :datetime not null
23 | #
24 | # Indexes
25 | #
26 | # unique_classification_target_per_type (target_id,target_type,model_used) UNIQUE
27 | #
28 |
--------------------------------------------------------------------------------
/config/locales/server.hu.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | hu:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Dátum"
11 | emotion_neutral:
12 | title: "\U0001F610 Semleges"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "Keresés"
17 | tags: "Címkék listázása"
18 | time: "Idő"
19 | summarize: "Összefoglalás"
20 | sentiment:
21 | reports:
22 | post_emotion:
23 | neutral: "Semleges \U0001F610"
24 | sentiment_analysis:
25 | neutral: "Semleges"
26 | ai_staff_action_logger:
27 | updated: "frissítve"
28 | removed: "eltávolítva"
29 |
--------------------------------------------------------------------------------
/lib/personas/image_captioner.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Personas
5 | class ImageCaptioner < Persona
6 | def self.default_enabled
7 | false
8 | end
9 |
10 | def system_prompt
11 | <<~PROMPT.strip
12 | You are a bot specializing in image captioning.
13 |
14 | Format your response as a JSON object with a single key named "output", which has the caption as the value.
15 | Your output should be in the following format:
16 |
19 |
20 | Where "xx" is replaced by the caption.
21 | PROMPT
22 | end
23 |
24 | def response_format
25 | [{ "key" => "output", "type" => "string" }]
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/db/post_migrate/20250404045050_migrate_users_to_email_group.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class MigrateUsersToEmailGroup < ActiveRecord::Migration[7.2]
3 | def up
4 | execute <<~SQL
5 | UPDATE discourse_automation_fields
6 | SET component = 'email_group_user'
7 | WHERE
8 | component = 'users' AND
9 | name = 'receivers' AND
10 | automation_id IN (SELECT id FROM discourse_automation_automations WHERE script = 'llm_report')
11 | SQL
12 | end
13 |
14 | def down
15 | execute <<~SQL
16 | UPDATE discourse_automation_fields
17 | SET component = 'users'
18 | WHERE
19 | component = 'email_group_user' AND
20 | name = 'receivers' AND
21 | automation_id IN (SELECT id FROM discourse_automation_automations WHERE script = 'llm_report')
22 | SQL
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/spec/lib/personas/tools/time_spec.rb:
--------------------------------------------------------------------------------
1 | #frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Personas::Tools::Time do
4 | fab!(:llm_model)
5 | let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
6 | let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }
7 |
8 | before do
9 | enable_current_plugin
10 | SiteSetting.ai_bot_enabled = true
11 | end
12 |
13 | describe "#process" do
14 | it "can generate correct info" do
15 | freeze_time
16 |
17 | args = { timezone: "America/Los_Angeles" }
18 | info = described_class.new(args, bot_user: bot_user, llm: llm).invoke
19 |
20 | expect(info).to eq({ args: args, time: Time.now.in_time_zone("America/Los_Angeles").to_s })
21 | expect(info.to_s).not_to include("not_here")
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/split-new-topic-category-after/ai-category-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { service } from "@ember/service";
3 | import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester";
4 | import { showPostAIHelper } from "../../lib/show-ai-helper";
5 |
6 | export default class AiCategorySuggestion extends Component {
7 | static shouldRender(outletArgs, helper) {
8 | return showPostAIHelper(outletArgs, helper);
9 | }
10 |
11 | @service siteSettings;
12 |
13 |
14 | {{#if this.siteSettings.ai_embeddings_enabled}}
15 |
20 | {{/if}}
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/lib/inference/discourse_reranker.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::DiscourseAi
4 | module Inference
5 | class DiscourseReranker
6 | def self.perform!(endpoint, model, content, candidates, api_key)
7 | headers = { "Referer" => Discourse.base_url, "Content-Type" => "application/json" }
8 |
9 | headers["X-API-KEY"] = api_key if api_key.present?
10 |
11 | conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
12 | response =
13 | conn.post(
14 | endpoint,
15 | { model: model, content: content, candidates: candidates }.to_json,
16 | headers,
17 | )
18 |
19 | raise Net::HTTPBadResponse unless response.status == 200
20 |
21 | JSON.parse(response.body, symbolize_names: true)
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/assets/javascripts/initializers/translation.js:
--------------------------------------------------------------------------------
1 | import { apiInitializer } from "discourse/lib/api";
2 | import cookie from "discourse/lib/cookie";
3 |
4 | export default apiInitializer((api) => {
5 | const settings = api.container.lookup("service:site-settings");
6 |
7 | if (!settings.discourse_ai_enabled || !settings.ai_translation_enabled) {
8 | return;
9 | }
10 |
11 | api.registerCustomPostMessageCallback(
12 | "localized",
13 | (topicController, data) => {
14 | if (!cookie("content-localization-show-original")) {
15 | const postStream = topicController.get("model.postStream");
16 | postStream.triggerChangedPost(data.id, data.updated_at).then(() => {
17 | topicController.appEvents.trigger("post-stream:refresh", {
18 | id: data.id,
19 | });
20 | });
21 | }
22 | }
23 | );
24 | });
25 |
--------------------------------------------------------------------------------
/assets/javascripts/initializers/ai-sentiment-admin-nav.js:
--------------------------------------------------------------------------------
1 | import { apiInitializer } from "discourse/lib/api";
2 |
3 | export default apiInitializer("1.15.0", (api) => {
4 | const currentUser = api.getCurrentUser();
5 |
6 | if (
7 | !currentUser ||
8 | !currentUser.admin ||
9 | !currentUser.can_see_sentiment_reports
10 | ) {
11 | return;
12 | }
13 |
14 | api.addAdminSidebarSectionLink("reports", {
15 | name: "sentiment_overview",
16 | route: "admin.dashboardSentiment",
17 | label: "discourse_ai.sentiments.sidebar.overview",
18 | icon: "chart-column",
19 | });
20 | api.addAdminSidebarSectionLink("reports", {
21 | name: "sentiment_analysis",
22 | route: "adminReports.show",
23 | routeModels: ["sentiment_analysis"],
24 | label: "discourse_ai.sentiments.sidebar.analysis",
25 | icon: "chart-pie",
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-edit.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class DiscourseAiToolsEditRoute extends DiscourseRoute {
4 | async model(params) {
5 | const allTools = this.modelFor("adminPlugins.show.discourse-ai-tools");
6 | const id = parseInt(params.id, 10);
7 |
8 | return allTools.find((tool) => tool.id === id);
9 | }
10 |
11 | setupController(controller) {
12 | super.setupController(...arguments);
13 | const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
14 |
15 | controller.set("allTools", toolsModel);
16 | controller.set("presets", toolsModel.resultSetMeta.presets);
17 | controller.set("llms", toolsModel.resultSetMeta.llms);
18 | controller.set("settings", toolsModel.resultSetMeta.settings);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs:
--------------------------------------------------------------------------------
1 | import RouteTemplate from "ember-route-template";
2 | import BackButton from "discourse/components/back-button";
3 | import SiteSettingComponent from "admin/components/site-setting";
4 |
5 | export default RouteTemplate(
6 |
7 |
11 |
15 |
16 |
17 | {{#each @model.feature_settings as |setting|}}
18 |
19 |
20 |
21 | {{/each}}
22 |
23 |
24 | );
25 |
--------------------------------------------------------------------------------
/app/serializers/ai_embedding_definition_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiEmbeddingDefinitionSerializer < ApplicationSerializer
4 | root "ai_embedding"
5 |
6 | attributes :id,
7 | :display_name,
8 | :dimensions,
9 | :max_sequence_length,
10 | :pg_function,
11 | :provider,
12 | :url,
13 | :api_key,
14 | :seeded,
15 | :tokenizer_class,
16 | :embed_prompt,
17 | :search_prompt,
18 | :matryoshka_dimensions,
19 | :provider_params
20 |
21 | def api_key
22 | object.seeded? ? "********" : object.api_key
23 | end
24 |
25 | def url
26 | object.seeded? ? "********" : object.url
27 | end
28 |
29 | def provider
30 | object.seeded? ? "CDCK" : object.provider
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/translation/entry_point.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Translation
5 | class EntryPoint
6 | def inject_into(plugin)
7 | plugin.on(:post_created) do |post|
8 | if DiscourseAi::Translation.enabled?
9 | Jobs.enqueue(:detect_translate_post, post_id: post.id)
10 | end
11 | end
12 |
13 | plugin.on(:topic_created) do |topic|
14 | if DiscourseAi::Translation.enabled?
15 | Jobs.enqueue(:detect_translate_topic, topic_id: topic.id)
16 | end
17 | end
18 |
19 | plugin.on(:post_edited) do |post, topic_changed|
20 | if DiscourseAi::Translation.enabled? && topic_changed
21 | Jobs.enqueue(:detect_translate_topic, topic_id: post.topic_id)
22 | end
23 | end
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/spec/system/page_objects/components/ai_summary_box.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module PageObjects
4 | module Components
5 | class AiSummaryTrigger < PageObjects::Components::Base
6 | SUMMARY_BUTTON_SELECTOR = ".ai-summarization-button"
7 | SUMMARY_CONTAINER_SELECTOR = ".ai-summary-modal"
8 |
9 | def click_summarize
10 | find(SUMMARY_BUTTON_SELECTOR).click
11 | end
12 |
13 | def click_regenerate_summary
14 | find("#{SUMMARY_CONTAINER_SELECTOR} .d-modal__footer button").click
15 | end
16 |
17 | def has_summary?(summary)
18 | find("#{SUMMARY_CONTAINER_SELECTOR} .generated-summary p").text == summary
19 | end
20 |
21 | def has_generating_summary_indicator?
22 | find("#{SUMMARY_CONTAINER_SELECTOR} .ai-summary__generating-text").present?
23 | end
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/split-new-topic-tag-after/ai-tag-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { service } from "@ember/service";
3 | import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester";
4 | import { showPostAIHelper } from "../../lib/show-ai-helper";
5 |
6 | export default class AiTagSuggestion extends Component {
7 | static shouldRender(outletArgs, helper) {
8 | return showPostAIHelper(outletArgs, helper);
9 | }
10 |
11 | @service siteSettings;
12 |
13 |
14 | {{#if this.siteSettings.ai_embeddings_enabled}}
15 |
21 | {{/if}}
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/app/models/ai_spam_log.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AiSpamLog < ActiveRecord::Base
3 | belongs_to :post
4 | belongs_to :llm_model
5 | belongs_to :ai_api_audit_log
6 | belongs_to :reviewable
7 | end
8 |
9 | # == Schema Information
10 | #
11 | # Table name: ai_spam_logs
12 | #
13 | # id :bigint not null, primary key
14 | # post_id :bigint not null
15 | # llm_model_id :bigint not null
16 | # ai_api_audit_log_id :bigint
17 | # reviewable_id :bigint
18 | # is_spam :boolean not null
19 | # payload :string(20000) default(""), not null
20 | # created_at :datetime not null
21 | # updated_at :datetime not null
22 | # error :string(3000)
23 | #
24 | # Indexes
25 | #
26 | # index_ai_spam_logs_on_post_id (post_id)
27 | #
28 |
--------------------------------------------------------------------------------
/db/migrate/20250721192553_enable_ai_if_already_installed.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class EnableAiIfAlreadyInstalled < ActiveRecord::Migration[7.2]
4 | def up
5 | installed_at = DB.query_single(<<~SQL)&.first
6 | SELECT created_at FROM schema_migration_details WHERE version='20230224165056'
7 | SQL
8 |
9 | if installed_at && installed_at < 1.hour.ago
10 | # The plugin was installed before we changed it to be disabled-by-default
11 | # Therefore, if there is no existing database value, enable the plugin
12 | execute <<~SQL
13 | INSERT INTO site_settings(name, data_type, value, created_at, updated_at)
14 | VALUES('discourse_ai_enabled', 5, 't', NOW(), NOW())
15 | ON CONFLICT (name) DO NOTHING
16 | SQL
17 | end
18 | end
19 |
20 | def down
21 | raise ActiveRecord::IrreversibleMigration
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/app/jobs/regular/generate_embeddings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Jobs
4 | class GenerateEmbeddings < ::Jobs::Base
5 | sidekiq_options queue: "low"
6 |
7 | def execute(args)
8 | return unless DiscourseAi::Embeddings.enabled?
9 | return if args[:target_type].blank? || args[:target_id].blank?
10 | target = args[:target_type].constantize.find_by_id(args[:target_id])
11 | return if target.nil? || target.deleted_at.present?
12 |
13 | topic = target.is_a?(Topic) ? target : target.topic
14 | post = target.is_a?(Post) ? target : target.first_post
15 | return if topic.blank? || post.blank?
16 | return if topic.private_message? && !SiteSetting.ai_embeddings_generate_for_pms
17 | return if post.raw.blank?
18 |
19 | DiscourseAi::Embeddings::Vector.instance.generate_representation_from(target)
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-new.js:
--------------------------------------------------------------------------------
1 | import DiscourseRoute from "discourse/routes/discourse";
2 |
3 | export default class DiscourseAiToolsNewRoute extends DiscourseRoute {
4 | beforeModel(transition) {
5 | this.preset = transition.to.queryParams.presetId || "empty_tool";
6 | }
7 |
8 | async model() {
9 | return this.store.createRecord("ai-tool");
10 | }
11 |
12 | setupController(controller) {
13 | super.setupController(...arguments);
14 | const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
15 | controller.set("allTools", toolsModel);
16 | controller.set("presets", toolsModel.resultSetMeta.presets);
17 | controller.set("llms", toolsModel.resultSetMeta.llms);
18 | controller.set("settings", toolsModel.resultSetMeta.settings);
19 | controller.set("selectedPreset", this.preset);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/reviewable-ai-post.hbs:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
14 |
15 | {{#if @reviewable.blank_post}}
16 |
{{i18n "review.deleted_post"}}
17 | {{else}}
18 | {{html-safe @reviewable.cooked}}
19 | {{/if}}
20 |
21 |
22 | {{yield}}
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/spec/system/page_objects/pages/user_preferences_ai.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module PageObjects
4 | module Pages
5 | class UserPreferencesAi < PageObjects::Pages::Base
6 | def visit(user)
7 | page.visit("/u/#{user.username}/preferences/ai")
8 | self
9 | end
10 |
11 | def has_ai_preference_checked?(preference)
12 | page.find(".#{preference} input").checked?
13 | end
14 |
15 | def has_ai_preference?(preference)
16 | page.has_css?(".#{preference} input")
17 | end
18 |
19 | def has_no_ai_preference?(preference)
20 | page.has_no_css?(".#{preference} input")
21 | end
22 |
23 | def toggle_setting(preference)
24 | page.find(".#{preference} input").click
25 | end
26 |
27 | def save_changes
28 | page.find(".save-changes").click
29 | end
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { service } from "@ember/service";
3 | import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester";
4 | import { showComposerAiHelper } from "../../lib/show-ai-helper";
5 |
6 | export default class AiCategorySuggestion extends Component {
7 | static shouldRender(outletArgs, helper) {
8 | return showComposerAiHelper(
9 | outletArgs?.composer,
10 | helper.siteSettings,
11 | helper.currentUser,
12 | "suggestions"
13 | );
14 | }
15 |
16 | @service composer;
17 |
18 |
19 | {{#unless this.composer.disableCategoryChooser}}
20 |
24 | {{/unless}}
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/spec/lib/personas/tools/list_tags_spec.rb:
--------------------------------------------------------------------------------
1 | #frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Personas::Tools::ListTags do
4 | fab!(:llm_model)
5 | let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
6 | let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }
7 |
8 | before do
9 | enable_current_plugin
10 | SiteSetting.ai_bot_enabled = true
11 | SiteSetting.tagging_enabled = true
12 | end
13 |
14 | describe "#process" do
15 | it "can generate correct info" do
16 | Fabricate(:tag, name: "america", public_topic_count: 100)
17 | Fabricate(:tag, name: "not_here", public_topic_count: 0)
18 |
19 | info = described_class.new({}, bot_user: bot_user, llm: llm).invoke
20 |
21 | expect(info.to_s).to include("america")
22 | expect(info.to_s).not_to include("not_here")
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/jobs/regular/create_ai_chat_reply.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::Jobs
4 | class CreateAiChatReply < ::Jobs::Base
5 | sidekiq_options retry: false
6 |
7 | def execute(args)
8 | channel = ::Chat::Channel.find_by(id: args[:channel_id])
9 | return if channel.blank?
10 |
11 | message = ::Chat::Message.find_by(id: args[:message_id])
12 | return if message.blank?
13 |
14 | personaClass =
15 | DiscourseAi::Personas::Persona.find_by(id: args[:persona_id], user: message.user)
16 | return if personaClass.blank?
17 |
18 | user = User.find_by(id: personaClass.user_id)
19 | bot = DiscourseAi::Personas::Bot.as(user, persona: personaClass.new)
20 |
21 | DiscourseAi::AiBot::Playground.new(bot).reply_to_chat_message(
22 | message,
23 | channel,
24 | args[:context_post_ids],
25 | )
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/config/locales/server.sv.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | sv:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Datum"
11 | emotion_neutral:
12 | title: "\U0001F610 Neutralt"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "Sök"
17 | time: "Tid"
18 | summarize: "Sammanfatta"
19 | summarization:
20 | chat:
21 | no_targets: "Det fanns inga meddelanden under den valda perioden."
22 | sentiment:
23 | reports:
24 | post_emotion:
25 | neutral: "Neutralt \U0001F610"
26 | sentiment_analysis:
27 | neutral: "Neutralt"
28 | ai_staff_action_logger:
29 | updated: "uppdaterade"
30 | removed: "borttagen"
31 |
--------------------------------------------------------------------------------
/db/migrate/20230710171141_enable_pg_vector_extension.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class EnablePgVectorExtension < ActiveRecord::Migration[7.0]
4 | def change
5 | begin
6 | enable_extension :vector
7 | rescue Exception => e
8 | if DB.query_single("SELECT 1 FROM pg_available_extensions WHERE name = 'vector';").empty?
9 | STDERR.puts "------------------------------DISCOURSE AI ERROR----------------------------------"
10 | STDERR.puts " Discourse AI requires the pgvector extension on the PostgreSQL database."
11 | STDERR.puts " Run a `./launcher rebuild app` to fix it on a standard install."
12 | STDERR.puts " Alternatively, you can remove Discourse AI to rebuild."
13 | STDERR.puts "------------------------------DISCOURSE AI ERROR----------------------------------"
14 | end
15 | raise e
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/controllers/discourse_ai/admin/ai_usage_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Admin
5 | class AiUsageController < ::Admin::AdminController
6 | requires_plugin "discourse-ai"
7 |
8 | def show
9 | end
10 |
11 | def report
12 | render json: AiUsageSerializer.new(create_report, root: false)
13 | end
14 |
15 | private
16 |
17 | def create_report
18 | report =
19 | DiscourseAi::Completions::Report.new(
20 | start_date: params[:start_date]&.to_date || 30.days.ago,
21 | end_date: params[:end_date]&.to_date || Time.current,
22 | )
23 |
24 | report = report.filter_by_feature(params[:feature]) if params[:feature].present?
25 | report = report.filter_by_model(params[:model]) if params[:model].present?
26 | report
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/javascripts/integration/components/ai-indicator-wave-test.gjs:
--------------------------------------------------------------------------------
1 | import { render } from "@ember/test-helpers";
2 | import { module, test } from "qunit";
3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test";
4 | import AiIndicatorWave from "discourse/plugins/discourse-ai/discourse/components/ai-indicator-wave";
5 |
6 | module("Integration | Component | ai-indicator-wave", function (hooks) {
7 | setupRenderingTest(hooks);
8 |
9 | test("it renders an indicator wave", async function (assert) {
10 | await render();
11 | assert.dom(".ai-indicator-wave").exists();
12 | });
13 |
14 | test("it does not render the indicator wave when loading is false", async function (assert) {
15 | await render();
16 | assert.dom(".ai-indicator-wave").doesNotExist();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/config/locales/server.ug.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | ug:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "چېسلا"
11 | emotion_neutral:
12 | title: "\U0001F610 بىتەرەپ"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "ئىزدە"
17 | tags: "بەلگە تىزىمىنى كۆرسىتىدۇ"
18 | time: "ۋاقىت"
19 | summarize: "خۇلاسە"
20 | summarization:
21 | chat:
22 | no_targets: "تاللىغان مەزگىلدىكى ئۇچۇر يوق."
23 | sentiment:
24 | reports:
25 | post_emotion:
26 | neutral: "بىتەرەپ \U0001F610"
27 | sentiment_analysis:
28 | neutral: "بىتەرەپ"
29 | ai_staff_action_logger:
30 | updated: "يېڭىلاندى"
31 | removed: "چىقىرىۋېتىلدى"
32 |
--------------------------------------------------------------------------------
/db/migrate/20241008054440_create_binary_indexes_for_embeddings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateBinaryIndexesForEmbeddings < ActiveRecord::Migration[7.1]
4 | def up
5 | %w[topic post document_fragment].each do |type|
6 | # our supported embeddings models IDs and dimensions
7 | [
8 | [1, 768],
9 | [2, 1536],
10 | [3, 1024],
11 | [4, 1024],
12 | [5, 768],
13 | [6, 1536],
14 | [7, 2000],
15 | [8, 1024],
16 | ].each { |model_id, dimensions| execute <<-SQL }
17 | CREATE INDEX ai_#{type}_embeddings_#{model_id}_1_search_bit ON ai_#{type}_embeddings
18 | USING hnsw ((binary_quantize(embeddings)::bit(#{dimensions})) bit_hamming_ops)
19 | WHERE model_id = #{model_id} AND strategy_id = 1;
20 | SQL
21 | end
22 | end
23 |
24 | def down
25 | raise ActiveRecord::IrreversibleMigration
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/multisite_hash.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | class MultisiteHash
5 | def initialize(id)
6 | @hash = Hash.new { |h, k| h[k] = {} }
7 | @id = id
8 |
9 | MessageBus.subscribe(channel_name) { |message| @hash[message.data] = {} }
10 | end
11 |
12 | def channel_name
13 | "/multisite-hash-#{@id}"
14 | end
15 |
16 | def current_db
17 | RailsMultisite::ConnectionManagement.current_db
18 | end
19 |
20 | def fetch(key)
21 | @hash[current_db][key] ||= yield
22 | end
23 |
24 | def [](key)
25 | @hash.dig(current_db, key)
26 | end
27 |
28 | def []=(key, val)
29 | @hash[current_db][key] = val
30 | end
31 |
32 | def flush!
33 | @hash[current_db] = {}
34 | MessageBus.publish(channel_name, current_db)
35 | end
36 |
37 | # TODO implement a GC so we don't retain too much memory
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/personas/custom_prompt.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DiscourseAi
4 | module Personas
5 | class CustomPrompt < Persona
6 | def self.default_enabled
7 | false
8 | end
9 |
10 | def system_prompt
11 | <<~PROMPT.strip
12 | You are a helpful assistant. I will give you instructions inside XML tags.
13 | You will look at them and reply with a result.
14 |
15 | Format your response as a JSON object with a single key named "output", which has the result as the value.
16 | Your output should be in the following format:
17 |
20 |
21 | Where "xx" is replaced by the result.
22 | PROMPT
23 | end
24 |
25 | def response_format
26 | [{ "key" => "output", "type" => "string" }]
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/spec/system/page_objects/modals/diff_modal.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module PageObjects
4 | module Modals
5 | class DiffModal < PageObjects::Modals::Base
6 | def visible?
7 | page.has_css?(".composer-ai-helper-modal", wait: 5)
8 | end
9 |
10 | def confirm_changes
11 | find(".d-modal__footer button.confirm", wait: 5).click
12 | end
13 |
14 | def discard_changes
15 | find(".d-modal__footer button.discard", wait: 5).click
16 | end
17 |
18 | def old_value
19 | find(".composer-ai-helper-modal__old-value").text
20 | end
21 |
22 | def new_value
23 | find(".composer-ai-helper-modal__new-value").text
24 | end
25 |
26 | def has_diff?(old_value, new_value)
27 | has_css?(".inline-diff ins", text: new_value) &&
28 | has_css?(".inline-diff del", text: old_value)
29 | end
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/.discourse-compatibility:
--------------------------------------------------------------------------------
1 | < 3.5.0.beta8-dev: 5d80a34589d63e381d80273828072badc18955b1
2 | < 3.5.0.beta7-dev: cd14b0c0bee0cf63c59b64b6f7213e31a37f11a7
3 | < 3.5.0.beta6-dev: 3e74eea1e5e3143888d67a8d8a11206df214dc24
4 | < 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
5 | < 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
6 | < 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
7 | < 3.4.0.beta4-dev: a53719ab8eb071459f215227421b3ea4987e5f87
8 | < 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
9 | < 3.4.0.beta3-dev: decf1bb49d737ea15308400f22f89d1d1e71d13d
10 | < 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
11 | < 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
12 | < 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1
13 | < 3.3.0.beta1-dev: d94234d89f50733678a5c62809445198a9fda74b
14 | 3.2.999: d94234d89f50733678a5c62809445198a9fda74b
15 | 3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
16 |
--------------------------------------------------------------------------------
/spec/serializers/ai_chat_channel_serializer_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe AiChatChannelSerializer do
4 | fab!(:admin)
5 |
6 | before { enable_current_plugin }
7 |
8 | describe "#title" do
9 | context "when the channel is a DM" do
10 | fab!(:dm_channel) { Fabricate(:direct_message_channel) }
11 |
12 | it "display every participant" do
13 | serialized = described_class.new(dm_channel, scope: Guardian.new(admin), root: nil)
14 |
15 | expect(serialized.title).to eq(dm_channel.title(nil))
16 | end
17 | end
18 |
19 | context "when the channel is a regular one" do
20 | fab!(:channel) { Fabricate(:chat_channel) }
21 |
22 | it "displays the category title" do
23 | serialized = described_class.new(channel, scope: Guardian.new(admin), root: nil)
24 |
25 | expect(serialized.title).to eq(channel.title)
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/app/serializers/ai_tool_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiToolSerializer < ApplicationSerializer
4 | attributes :options, :id, :name, :help
5 |
6 | def include_options?
7 | object.accepted_options.present?
8 | end
9 |
10 | def id
11 | object.to_s.split("::").last
12 | end
13 |
14 | def name
15 | object.name.humanize.titleize
16 | end
17 |
18 | def help
19 | object.help
20 | end
21 |
22 | def options
23 | options = {}
24 | object.accepted_options.each do |option|
25 | processed_option = {
26 | name: option.localized_name,
27 | description: option.localized_description,
28 | type: option.type,
29 | }
30 | processed_option[:values] = option.values if option.values.present?
31 | processed_option[:default] = option.default if option.default.present?
32 | options[option.name] = processed_option
33 | end
34 | options
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/post-menu/ai-debug-button.gjs:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { action } from "@ember/object";
3 | import { service } from "@ember/service";
4 | import DButton from "discourse/components/d-button";
5 | import { isPostFromAiBot } from "../../lib/ai-bot-helper";
6 | import DebugAiModal from "../modal/debug-ai-modal";
7 |
8 | export default class AiDebugButton extends Component {
9 | static shouldRender(args) {
10 | return isPostFromAiBot(args.post, args.state.currentUser);
11 | }
12 |
13 | @service modal;
14 |
15 | @action
16 | debugAiResponse() {
17 | this.modal.show(DebugAiModal, { model: this.args.post });
18 | }
19 |
20 |
21 |
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/spec/reports/sentiment_analysis_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Sentiment::SentimentAnalysisReport do
4 | fab!(:admin)
5 | fab!(:category)
6 | fab!(:topic) { Fabricate(:topic, category: category) }
7 | fab!(:post) { Fabricate(:post, user: admin, topic: topic) }
8 | fab!(:post_2) { Fabricate(:post, user: admin, topic: topic) }
9 | fab!(:classification_result) { Fabricate(:classification_result, target: post) }
10 |
11 | before do
12 | enable_current_plugin
13 | SiteSetting.ai_sentiment_enabled = true
14 | end
15 |
16 | it "contains the correct filters" do
17 | report = Report.find("sentiment_analysis")
18 | expect(report.available_filters).to include("group_by", "sort_by", "category", "tag")
19 | end
20 |
21 | it "contains the correct labels" do
22 | report = Report.find("sentiment_analysis")
23 | expect(report.labels).to eq(%w[Positive Neutral Negative])
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/serializers/ai_inferred_concept_post_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AiInferredConceptPostSerializer < ApplicationSerializer
4 | attributes :id,
5 | :post_number,
6 | :topic_id,
7 | :topic_title,
8 | :username,
9 | :avatar_template,
10 | :created_at,
11 | :updated_at,
12 | :excerpt,
13 | :truncated,
14 | :inferred_concepts
15 |
16 | def avatar_template
17 | User.avatar_template(object.username, object.uploaded_avatar_id)
18 | end
19 |
20 | def excerpt
21 | Post.excerpt(object.cooked)
22 | end
23 |
24 | def truncated
25 | object.cooked.length > SiteSetting.post_excerpt_maxlength
26 | end
27 |
28 | def inferred_concepts
29 | ActiveModel::ArraySerializer.new(
30 | object.inferred_concepts,
31 | each_serializer: InferredConceptSerializer,
32 | )
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/config/locales/server.sk.yml:
--------------------------------------------------------------------------------
1 | # WARNING: Never edit this file.
2 | # It will be overwritten when translations are pulled from Crowdin.
3 | #
4 | # To work with us on translations, join this project:
5 | # https://translate.discourse.org/
6 |
7 | sk:
8 | reports:
9 | overall_sentiment:
10 | yaxis: "Dátum"
11 | emotion_neutral:
12 | title: "\U0001F610 Neutrálna"
13 | discourse_ai:
14 | ai_bot:
15 | tool_summary:
16 | search: "Hľadať"
17 | tags: "Zoznam značiek"
18 | time: "Čas"
19 | summarize: "Zhrnúť"
20 | summarization:
21 | chat:
22 | no_targets: "Počas vybraného obdobia neboli zaznamenané žiadne správy."
23 | sentiment:
24 | reports:
25 | post_emotion:
26 | neutral: "Neutrálna \U0001F610"
27 | sentiment_analysis:
28 | neutral: "Neutrálna"
29 | ai_staff_action_logger:
30 | updated: "aktualizované"
31 | removed: "odstránené"
32 |
--------------------------------------------------------------------------------
/spec/lib/personas/tools/db_schema_spec.rb:
--------------------------------------------------------------------------------
1 | #frozen_string_literal: true
2 |
3 | RSpec.describe DiscourseAi::Personas::Tools::DbSchema do
4 | fab!(:llm_model)
5 | let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
6 | let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }
7 |
8 | before do
9 | enable_current_plugin
10 | SiteSetting.ai_bot_enabled = true
11 | end
12 |
13 | describe "#process" do
14 | it "returns rich schema for tables" do
15 | result = described_class.new({ tables: "posts,topics" }, bot_user: bot_user, llm: llm).invoke
16 |
17 | expect(result[:schema_info]).to include("raw text")
18 | expect(result[:schema_info]).to include("views integer")
19 | expect(result[:schema_info]).to include("posts")
20 | expect(result[:schema_info]).to include("topics")
21 |
22 | expect(result[:tables]).to eq("posts,topics")
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------