├── 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 | 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 |
2 | 9 |
-------------------------------------------------------------------------------- /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 |
2 | 10 |
-------------------------------------------------------------------------------- /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 = ; 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 = ; 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 | 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 = ; 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 = ; 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 = ; 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 | 7 | 8 | 9 | 10 | 11 | {{/each-in}} 12 | 13 |
{{i18n "discourse_ai.reviewables.model_used"}}{{model}}{{i18n "discourse_ai.reviewables.accuracy"}}{{acc}}%
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 | 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 = ; 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 = ; 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 17 | {"output": "xx"} 18 | 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 | 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 | 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 | 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 |
2 | 3 | 4 |
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 | 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 | 18 | {"output": "xx"} 19 | 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 | 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 | --------------------------------------------------------------------------------