From 2c4e5e998ebe690eb679ff44f631938dbe64d5d4 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 28 Aug 2026 08:24:24 +1000 Subject: [PATCH] remove all files (#1516) --- Gemfile | 8 - Gemfile.lock | 100 - LICENSE | 21 - README.md | 33 +- about.json | 8 - ...ugins-show-discourse-ai-embeddings-edit.js | 21 - ...lugins-show-discourse-ai-embeddings-new.js | 17 - ...in-plugins-show-discourse-ai-embeddings.js | 7 - ...plugins-show-discourse-ai-features-edit.js | 35 - ...dmin-plugins-show-discourse-ai-features.js | 10 - ...min-plugins-show-discourse-ai-llms-edit.js | 28 - ...dmin-plugins-show-discourse-ai-llms-new.js | 25 - .../admin-plugins-show-discourse-ai-llms.js | 7 - ...plugins-show-discourse-ai-personas-edit.js | 19 - ...-plugins-show-discourse-ai-personas-new.js | 25 - ...dmin-plugins-show-discourse-ai-personas.js | 7 - .../admin-plugins-show-discourse-ai-spam.js | 11 - ...in-plugins-show-discourse-ai-tools-edit.js | 20 - ...min-plugins-show-discourse-ai-tools-new.js | 21 - .../admin-plugins-show-discourse-ai-tools.js | 10 - .../admin-plugins-show-discourse-ai-usage.js | 11 - .../show/discourse-ai-embeddings/edit.hbs | 4 - .../show/discourse-ai-embeddings/index.hbs | 1 - .../show/discourse-ai-embeddings/new.hbs | 4 - .../show/discourse-ai-features/edit.gjs | 24 - .../show/discourse-ai-features/index.hbs | 1 - .../show/discourse-ai-llms/edit.hbs | 1 - .../show/discourse-ai-llms/index.hbs | 1 - .../show/discourse-ai-llms/new.hbs | 5 - .../show/discourse-ai-personas/edit.hbs | 4 - .../show/discourse-ai-personas/index.hbs | 1 - .../show/discourse-ai-personas/new.hbs | 4 - .../admin-plugins/show/discourse-ai-spam.hbs | 1 - .../show/discourse-ai-tools/edit.hbs | 9 - .../show/discourse-ai-tools/index.hbs | 1 - .../show/discourse-ai-tools/new.hbs | 10 - .../admin-plugins/show/discourse-ai-usage.hbs | 1 - .../admin/ai_embeddings_controller.rb | 200 - .../admin/ai_features_controller.rb | 66 - .../admin/ai_llm_quotas_controller.rb | 59 - .../discourse_ai/admin/ai_llms_controller.rb | 293 -- .../admin/ai_personas_controller.rb | 403 -- .../discourse_ai/admin/ai_spam_controller.rb | 202 - .../discourse_ai/admin/ai_tools_controller.rb | 206 - .../discourse_ai/admin/ai_usage_controller.rb | 30 - .../admin/dashboard_controller.rb | 12 - .../rag_document_fragments_controller.rb | 76 - .../ai_bot/artifact_key_values_controller.rb | 124 - .../ai_bot/artifacts_controller.rb | 344 -- .../discourse_ai/ai_bot/bot_controller.rb | 108 - .../ai_bot/conversations_controller.rb | 39 - .../shared_ai_conversations_controller.rb | 136 - .../ai_helper/assistant_controller.rb | 241 - .../discourse_ai/discord/bot_controller.rb | 50 - .../embeddings/embeddings_controller.rb | 97 - .../sentiment/sentiment_controller.rb | 97 - .../summarization/chat_summary_controller.rb | 40 - .../summarization/summary_controller.rb | 46 - .../ai_bot/shared_ai_conversations_helper.rb | 31 - app/jobs/regular/ai_spam_scan.rb | 13 - app/jobs/regular/create_ai_chat_reply.rb | 28 - app/jobs/regular/create_ai_reply.rb | 26 - app/jobs/regular/detect_translate_post.rb | 61 - app/jobs/regular/detect_translate_topic.rb | 59 - app/jobs/regular/digest_rag_upload.rb | 204 - app/jobs/regular/fast_track_topic_gist.rb | 22 - .../regular/generate_chat_thread_title.rb | 21 - app/jobs/regular/generate_embeddings.rb | 22 - .../regular/generate_inferred_concepts.rb | 70 - app/jobs/regular/generate_rag_embeddings.rb | 25 - app/jobs/regular/localize_categories.rb | 53 - app/jobs/regular/localize_posts.rb | 71 - app/jobs/regular/localize_topics.rb | 68 - .../manage_embedding_def_search_index.rb | 13 - app/jobs/regular/post_sentiment_analysis.rb | 17 - ...red_conversation_adjust_upload_security.rb | 51 - app/jobs/regular/stream_composer_helper.rb | 27 - app/jobs/regular/stream_discord_reply.rb | 19 - app/jobs/regular/stream_discover_reply.rb | 57 - app/jobs/regular/stream_post_helper.rb | 42 - app/jobs/regular/stream_topic_ai_summary.rb | 50 - .../categories_locale_detection_backfill.rb | 36 - .../category_localization_backfill.rb | 15 - app/jobs/scheduled/embeddings_backfill.rb | 151 - .../generate_concepts_from_popular_items.rb | 87 - .../scheduled/post_localization_backfill.rb | 17 - .../posts_locale_detection_backfill.rb | 54 - .../scheduled/remove_orphaned_embeddings.rb | 11 - app/jobs/scheduled/sentiment_backfill.rb | 30 - app/jobs/scheduled/summaries_backfill.rb | 91 - .../scheduled/topic_localization_backfill.rb | 15 - .../topics_locale_detection_backfill.rb | 52 - app/mailers/ai_report_mailer.rb | 9 - app/models/ai_api_audit_log.rb | 55 - app/models/ai_artifact.rb | 119 - app/models/ai_artifact_key_value.rb | 56 - app/models/ai_artifact_version.rb | 40 - app/models/ai_moderation_setting.rb | 34 - app/models/ai_persona.rb | 432 -- app/models/ai_spam_log.rb | 27 - app/models/ai_summary.rb | 73 - app/models/ai_tool.rb | 482 -- app/models/chat_message_custom_prompt.rb | 20 - app/models/classification_result.rb | 27 - app/models/completion_prompt.rb | 88 - app/models/embedding_definition.rb | 244 - app/models/inferred_concept.rb | 25 - app/models/inferred_concept_post.rb | 25 - app/models/inferred_concept_topic.rb | 25 - app/models/llm_model.rb | 203 - app/models/llm_quota.rb | 85 - app/models/llm_quota_usage.rb | 120 - app/models/model_accuracy.rb | 52 - app/models/post_custom_prompt.rb | 24 - app/models/rag_document_fragment.rb | 93 - app/models/reviewable_ai_chat_message.rb | 188 - app/models/reviewable_ai_post.rb | 245 - app/models/shared_ai_conversation.rb | 232 - .../ai_api_audit_log_serializer.rb | 18 - .../ai_artifact_key_value_serializer.rb | 9 - app/serializers/ai_chat_channel_serializer.rb | 22 - .../ai_custom_tool_list_serializer.rb | 21 - app/serializers/ai_custom_tool_serializer.rb | 25 - .../ai_embedding_definition_serializer.rb | 32 - .../ai_features_persona_serializer.rb | 12 - .../ai_inferred_concept_post_serializer.rb | 34 - .../ai_sentiment_post_serializer.rb | 28 - app/serializers/ai_spam_serializer.rb | 70 - app/serializers/ai_tool_serializer.rb | 36 - .../ai_topic_summary_serializer.rb | 18 - app/serializers/ai_usage_serializer.rb | 79 - app/serializers/basic_llm_model_serializer.rb | 5 - .../inferred_concept_serializer.rb | 5 - app/serializers/llm_model_serializer.rb | 53 - app/serializers/llm_quota_serializer.rb | 9 - .../localized_ai_persona_serializer.rb | 63 - .../reviewable_ai_chat_message_serializer.rb | 19 - .../reviewable_ai_post_serializer.rb | 7 - app/services/discourse_ai/persona_exporter.rb | 80 - app/services/discourse_ai/persona_importer.rb | 149 - .../discourse_ai/topic_summarization.rb | 44 - app/services/problem_check/ai_llm_status.rb | 55 - .../shared_ai_conversations/show.html.erb | 80 - app/views/list/related_topics.html.erb | 58 - .../admin-discourse-ai-plugin-route-map.js | 37 - .../discourse/admin-discourse-ai-route-map.js | 10 - .../discourse/admin/adapters/ai-embedding.js | 21 - .../discourse/admin/adapters/ai-feature.js | 21 - .../discourse/admin/adapters/ai-llm.js | 21 - .../discourse/admin/adapters/ai-persona.js | 21 - .../discourse/admin/adapters/ai-tool.js | 21 - .../discourse/admin/models/ai-embedding.js | 41 - .../discourse/admin/models/ai-feature.js | 7 - .../discourse/admin/models/ai-llm.js | 37 - .../discourse/admin/models/ai-persona.js | 161 - .../discourse/admin/models/ai-tool.js | 49 - .../components/admin-report-emotion.hbs | 45 - .../components/admin-report-emotion.js | 39 - .../admin-report-sentiment-analysis.gjs | 452 -- .../discourse/components/ai-artifact.gjs | 188 - .../components/ai-blinking-animation.gjs | 115 - .../components/ai-bot-conversations.gjs | 381 -- .../components/ai-bot-header-icon.gjs | 79 - .../components/ai-bot-sidebar-empty-state.gjs | 7 - .../ai-bot-sidebar-new-conversation.gjs | 48 - .../components/ai-composer-helper-menu.gjs | 167 - .../components/ai-embedding-editor.gjs | 580 --- .../components/ai-embeddings-list-editor.gjs | 114 - .../discourse/components/ai-features-list.gjs | 243 - .../discourse/components/ai-features.gjs | 220 - .../components/ai-full-page-search.gjs | 258 -- .../discourse/components/ai-gist-toggle.gjs | 93 - .../components/ai-helper-custom-prompt.gjs | 42 - .../components/ai-helper-loading.gjs | 19 - .../components/ai-helper-options-list.gjs | 49 - .../components/ai-indicator-wave.gjs | 12 - .../components/ai-llm-editor-form.gjs | 632 --- .../discourse/components/ai-llm-editor.gjs | 16 - .../discourse/components/ai-llm-selector.gjs | 17 - .../components/ai-llms-list-editor.gjs | 244 - .../components/ai-persona-editor.gjs | 738 --- .../components/ai-persona-example.gjs | 67 - .../components/ai-persona-list-editor.gjs | 430 -- .../components/ai-persona-llm-selector.gjs | 205 - .../components/ai-persona-tool-options.gjs | 108 - .../components/ai-post-helper-menu.gjs | 411 -- .../components/ai-quota-duration-selector.gjs | 111 - .../ai-search-discoveries-tooltip.gjs | 49 - .../components/ai-search-discoveries.gjs | 328 -- .../ai-sentiment-horizontal-bar.gjs | 30 - .../discourse/components/ai-spam.gjs | 326 -- .../components/ai-split-topic-suggester.gjs | 169 - .../components/ai-summary-skeleton.gjs | 18 - .../components/ai-tool-editor-form.gjs | 416 -- .../discourse/components/ai-tool-editor.gjs | 39 - .../components/ai-tool-list-editor.gjs | 200 - .../discourse/components/ai-tool-selector.gjs | 13 - .../discourse/components/ai-topic-gist.gjs | 41 - .../discourse/components/ai-usage.gjs | 648 --- .../discourse/components/doughnut-chart.gjs | 109 - .../components/modal/ai-llm-quota-modal.gjs | 144 - .../ai-persona-response-format-editor.gjs | 113 - .../components/modal/ai-summary-modal.gjs | 234 - .../components/modal/ai-tool-test-modal.gjs | 83 - .../modal/chat-modal-channel-summary.gjs | 82 - .../components/modal/debug-ai-modal.gjs | 194 - .../discourse/components/modal/diff-modal.gjs | 250 - .../modal/share-full-topic-modal.gjs | 154 - .../components/modal/share-modal.gjs | 130 - .../components/modal/spam-test-modal.gjs | 99 - .../modal/thumbnail-suggestions.gjs | 110 - .../discourse/components/model-accuracies.hbs | 14 - .../post-menu/ai-cancel-streaming-button.gjs | 34 - .../components/post-menu/ai-debug-button.gjs | 29 - .../components/post-menu/ai-share-button.gjs | 39 - .../components/post/ai-persona-flair.gjs | 14 - .../discourse/components/rag-options-fk.gjs | 81 - .../components/rag-upload-progress.gjs | 87 - .../discourse/components/rag-uploader.gjs | 215 - .../components/reviewable-ai-chat-message.hbs | 33 - .../components/reviewable-ai-chat-message.js | 11 - .../components/reviewable-ai-post.hbs | 26 - .../ai-category-suggester.gjs | 175 - .../suggestion-menus/ai-tag-suggester.gjs | 219 - .../suggestion-menus/ai-title-suggester.gjs | 169 - .../components/thumbnail-suggestion-item.gjs | 43 - .../admin-sentiment-dashbboard.gjs | 17 - .../ai-category-suggestion.gjs | 26 - .../ai-tag-suggestion.gjs | 18 - .../ai-title-suggestion.gjs | 20 - .../after-d-editor/composer-open.hbs | 8 - .../after-d-editor/composer-open.js | 45 - .../search-result-decoration.gjs | 13 - .../topic-list-gist-toggle.gjs | 13 - .../ai-image-caption-loader.gjs | 19 - .../composer-fields/persona-llm-selector.gjs | 47 - .../ai-category-suggestion.gjs | 21 - .../ai-tag-suggestion.gjs | 18 - .../ai-title-suggestion.gjs | 18 - .../ai-image-caption-container.gjs | 94 - .../ai-edit-suggestion-button.gjs | 68 - .../ai-full-page-discobot-discoveries.gjs | 53 - .../ai-full-page-search.gjs | 17 - .../ai-post-helper-trigger.gjs | 154 - .../ai-quick-search-loader.gjs | 15 - .../ai-quick-semantic-search.gjs | 76 - .../ai-discobot-discoveries.gjs | 47 - .../ai-quick-search-info.gjs | 31 - .../ai-category-suggestion.gjs | 22 - .../ai-tag-suggestion.gjs | 23 - .../ai-title-suggestion.gjs | 17 - .../ai-topic-gist-placement.gjs | 11 - .../ai-summary-trigger.gjs | 38 - .../ai-conversation-invite.gjs | 55 - .../user-preferences-nav/ai-preferences.gjs | 19 - .../controllers/admin-dashboard-sentiment.js | 50 - .../discourse/controllers/preferences-ai.js | 73 - .../discourse-ai-bot-dashboard-route-map.js | 5 - ...e-ai-shared-conversation-show-route-map.js | 5 - .../discourse/lib/ai-bot-helper.js | 102 - .../discourse/lib/ai-helper-suggestions.js | 34 - .../lib/ai-streamer/progress-handlers.js | 213 - .../lib/ai-streamer/updaters/post-updater.js | 78 - .../ai-streamer/updaters/stream-updater.js | 24 - .../discourse/lib/ai-topic-summary.js | 72 - .../discourse/lib/copy-conversation.js | 60 - .../discourse/lib/diff-streamer.gjs | 335 -- .../discourse/lib/show-ai-helper.js | 29 - .../discourse/lib/smooth-streamer.gjs | 116 - assets/javascripts/discourse/lib/utilities.js | 35 - .../discourse/preferences-ai-route-map.js | 7 - .../discourse-ai-shared-conversation-show.js | 19 - .../discourse/routes/preferences-ai.js | 15 - .../ai-bot-conversations-hidden-submit.js | 98 - .../ai-conversations-sidebar-manager.js | 339 -- .../services/discobot-discoveries.js | 37 - .../javascripts/discourse/services/gists.js | 23 - .../discourse/services/image-caption-popup.js | 44 - .../discourse/services/quick-search.js | 7 - .../templates/admin-dashboard-sentiment.hbs | 73 - .../discourse-ai-bot-conversations.gjs | 4 - .../discourse/templates/preferences/ai.gjs | 40 - .../admin-plugin-configuration-nav.js | 52 - .../javascripts/initializers/admin-reports.js | 34 - .../javascripts/initializers/ai-artifacts.gjs | 75 - .../initializers/ai-bot-replies.js | 181 - .../initializers/ai-chat-summarization.js | 30 - .../initializers/ai-conversations-sidebar.js | 64 - .../initializers/ai-gist-topic-list-class.js | 19 - assets/javascripts/initializers/ai-helper.js | 90 - .../initializers/ai-image-caption.js | 249 - .../initializers/ai-search-discoveries.js | 15 - .../initializers/ai-semantic-search.js | 9 - .../initializers/ai-sentiment-admin-nav.js | 27 - .../initializers/related-topics.gjs | 78 - .../javascripts/initializers/translation.js | 24 - .../lib/discourse-markdown/ai-tags.js | 11 - .../common/ai-blinking-animation.scss | 139 - assets/stylesheets/common/ai-features.scss | 160 - .../stylesheets/common/ai-user-settings.scss | 13 - assets/stylesheets/common/streaming.scss | 130 - .../modules/ai-bot-conversations/common.scss | 517 --- .../modules/ai-bot/common/ai-artifact.scss | 151 - .../common/ai-discobot-discoveries.scss | 285 -- .../modules/ai-bot/common/ai-persona.scss | 333 -- .../modules/ai-bot/common/ai-tools.scss | 64 - .../modules/ai-bot/common/bot-replies.scss | 150 - .../modules/ai-bot/mobile/ai-persona.scss | 7 - .../modules/ai-helper/common/ai-helper.scss | 762 ---- .../desktop/ai-helper-fk-modals.scss | 9 - .../modules/ai-helper/mobile/ai-helper.scss | 74 - .../common/ai-embedding-editor.scss | 74 - .../common/semantic-related-topics.scss | 15 - .../embeddings/common/semantic-search.scss | 104 - .../modules/llms/common/ai-llm-quotas.scss | 71 - .../modules/llms/common/ai-llms-editor.scss | 142 - .../stylesheets/modules/llms/common/spam.scss | 107 - .../modules/llms/common/usage.scss | 154 - .../modules/sentiment/common/dashboard.scss | 301 -- .../summarization/common/ai-gists.scss | 74 - .../summarization/common/ai-summary.scss | 102 - .../summarization/desktop/ai-summary.scss | 26 - config/eval-llms.yml | 121 - config/locales/client.ar.yml | 706 --- config/locales/client.be.yml | 144 - config/locales/client.bg.yml | 175 - config/locales/client.bs_BA.yml | 163 - config/locales/client.ca.yml | 170 - config/locales/client.cs.yml | 339 -- config/locales/client.da.yml | 186 - config/locales/client.de.yml | 896 ---- config/locales/client.el.yml | 215 - config/locales/client.en.yml | 942 ---- config/locales/client.en_GB.yml | 22 - config/locales/client.es.yml | 686 --- config/locales/client.et.yml | 165 - config/locales/client.fa_IR.yml | 235 - config/locales/client.fi.yml | 686 --- config/locales/client.fr.yml | 686 --- config/locales/client.gl.yml | 176 - config/locales/client.he.yml | 831 ---- config/locales/client.hr.yml | 196 - config/locales/client.hu.yml | 193 - config/locales/client.hy.yml | 169 - config/locales/client.id.yml | 289 -- config/locales/client.it.yml | 686 --- config/locales/client.ja.yml | 683 --- config/locales/client.ko.yml | 192 - config/locales/client.lt.yml | 356 -- config/locales/client.lv.yml | 165 - config/locales/client.nb_NO.yml | 175 - config/locales/client.nl.yml | 686 --- config/locales/client.pl_PL.yml | 617 --- config/locales/client.pt.yml | 181 - config/locales/client.pt_BR.yml | 686 --- config/locales/client.ro.yml | 184 - config/locales/client.ru.yml | 696 --- config/locales/client.sk.yml | 204 - config/locales/client.sl.yml | 177 - config/locales/client.sq.yml | 161 - config/locales/client.sr.yml | 155 - config/locales/client.sv.yml | 198 - config/locales/client.sw.yml | 154 - config/locales/client.te.yml | 170 - config/locales/client.th.yml | 156 - config/locales/client.tr_TR.yml | 687 --- config/locales/client.ug.yml | 196 - config/locales/client.uk.yml | 482 -- config/locales/client.ur.yml | 184 - config/locales/client.vi.yml | 182 - config/locales/client.zh_CN.yml | 704 --- config/locales/client.zh_TW.yml | 187 - config/locales/server.ar.yml | 480 -- config/locales/server.be.yml | 20 - config/locales/server.bg.yml | 18 - config/locales/server.bs_BA.yml | 17 - config/locales/server.ca.yml | 25 - config/locales/server.cs.yml | 38 - config/locales/server.da.yml | 26 - config/locales/server.de.yml | 614 --- config/locales/server.el.yml | 18 - config/locales/server.en.yml | 640 --- config/locales/server.en_GB.yml | 7 - config/locales/server.es.yml | 445 -- config/locales/server.et.yml | 23 - config/locales/server.fa_IR.yml | 39 - config/locales/server.fi.yml | 445 -- config/locales/server.fr.yml | 445 -- config/locales/server.gl.yml | 26 - config/locales/server.he.yml | 527 --- config/locales/server.hr.yml | 19 - config/locales/server.hu.yml | 28 - config/locales/server.hy.yml | 26 - config/locales/server.id.yml | 75 - config/locales/server.it.yml | 445 -- config/locales/server.ja.yml | 434 -- config/locales/server.ko.yml | 27 - config/locales/server.lt.yml | 32 - config/locales/server.lv.yml | 17 - config/locales/server.nb_NO.yml | 18 - config/locales/server.nl.yml | 445 -- config/locales/server.pl_PL.yml | 286 -- config/locales/server.pt.yml | 19 - config/locales/server.pt_BR.yml | 444 -- config/locales/server.ro.yml | 18 - config/locales/server.ru.yml | 463 -- config/locales/server.sk.yml | 31 - config/locales/server.sl.yml | 15 - config/locales/server.sq.yml | 14 - config/locales/server.sr.yml | 12 - config/locales/server.sv.yml | 30 - config/locales/server.sw.yml | 15 - config/locales/server.te.yml | 26 - config/locales/server.th.yml | 17 - config/locales/server.tr_TR.yml | 446 -- config/locales/server.ug.yml | 31 - config/locales/server.uk.yml | 354 -- config/locales/server.ur.yml | 27 - config/locales/server.vi.yml | 19 - config/locales/server.zh_CN.yml | 434 -- config/locales/server.zh_TW.yml | 27 - config/routes.rb | 141 - config/settings.yml | 596 --- db/fixtures/ai_bot/602_bot_users.rb | 3 - db/fixtures/personas/603_ai_personas.rb | 91 - ...056_create_classification_results_table.rb | 19 - ...0307125342_created_model_accuracy_table.rb | 16 - ...184514_migrate_discourse_ai_reviewables.rb | 13 - ...16160714_create_completion_prompt_table.rb | 15 - ...122645_delete_duplicated_seeded_prompts.rb | 18 - ...185619_multi_message_completion_prompts.rb | 7 - ...0320191928_drop_completion_prompt_value.rb | 7 - ...2028_make_dropped_value_column_nullable.rb | 14 - ...5943_add_provider_to_completion_prompts.rb | 22 - ...20230424055354_create_ai_api_audit_logs.rb | 15 - .../20230519003106_post_custom_prompts.rb | 13 - ...230710171141_enable_pg_vector_extension.rb | 18 - ...171142_create_ai_topic_embeddings_table.rb | 27 - ...rate_embeddings_from_dedicated_database.rb | 63 - ...ate_multilingual_topic_embeddings_table.rb | 16 - ...e_ai_helper_add_ai_pm_to_header_setting.rb | 11 - ...55701_create_bge_topic_embeddings_table.rb | 16 - ...38_add_topic_id_post_id_to_ai_audit_log.rb | 8 - .../20231109011155_create_ai_personas.rb | 18 - ..._add_system_and_priority_to_ai_personas.rb | 8 - .../20231120033747_remove_site_settings.rb | 12 - ...03_switch_to_generic_completion_prompts.rb | 9 - ...8151234_recreate_generate_titles_prompt.rb | 11 - ...50_convert_ai_personas_commands_to_json.rb | 31 - ...01_create_gemini_topic_embeddings_table.rb | 16 - ...213036_create_ai_post_embeddings_tables.rb | 60 - ...4013944_add_params_to_completion_prompt.rb | 8 - ...2348_explicit_provider_backwards_compat.rb | 91 - ...358_create_openai_text_embedding_tables.rb | 49 - ...52_add_temperature_top_p_to_ai_personas.rb | 8 - ...7144910_fix_llm_backed_setting_defaults.rb | 47 - ..._mentionable_default_llm_to_ai_personas.rb | 11 - ...20240213051213_add_limits_to_ai_persona.rb | 9 - ...40309034751_add_shared_ai_conversations.rb | 27 - ...4752_create_rag_document_fragment_table.rb | 13 - ...165121_embedding_tables_for_rag_uploads.rb | 96 - ...0240322035907_add_images_to_ai_personas.rb | 10 - ...38_add_metadata_to_rag_document_frament.rb | 8 - ...0409035951_add_rag_params_to_ai_persona.rb | 11 - ...0170000_add_embeddings_tablesfor_bge_m3.rb | 38 - ..._add_auto_image_caption_to_user_options.rb | 7 - ...consolidated_question_llm_to_ai_persona.rb | 7 - ...0503034946_add_allow_chat_to_ai_persona.rb | 7 - ...03042558_add_chat_message_custom_prompt.rb | 12 - .../20240504222307_create_llm_model_table.rb | 14 - ...34_add_feature_name_to_ai_api_audit_log.rb | 7 - ...14171609_add_endpoint_data_to_llm_model.rb | 8 - ...218_add_language_model_to_ai_audit_logs.rb | 6 - ...8132059_add_companion_user_to_llm_model.rb | 8 - .../20240528144216_seed_open_ai_models.rb | 108 - .../20240531205234_seed_claude_models.rb | 109 - ...0603133432_seed_other_propietary_models.rb | 97 - db/migrate/20240603143158_seed_oss_models.rb | 74 - ...0240606151348_create_ai_summaries_table.rb | 15 - ...7_copy_summary_sections_to_ai_summaries.rb | 16 - ...061418_tool_details_and_command_removal.rb | 17 - ...09232736_drop_commands_from_ai_personas.rb | 10 - ...n_strategy_to_ai_summarization_strategy.rb | 17 - ..._ai_custom_summarization_allowed_groups.rb | 19 - .../20240611170904_upgrade_pgvector_070.rb | 17 - ...ove_embeddings_to_single_table_per_type.rb | 159 - ...240611170906_drop_old_embeddings_tables.rb | 34 - db/migrate/20240618080148_create_ai_tools.rb | 19 - ...3057_choose_llm_model_setting_migration.rb | 40 - ...9211337_update_automation_script_models.rb | 56 - .../20240624135356_llm_model_custom_params.rb | 6 - ..._provider_specific_params_to_llm_models.rb | 41 - ...0703135444_llm_models_for_summarization.rb | 60 - ...0704020102_reset_identity_on_ai_summary.rb | 23 - .../20240708193243_fix_vllm_model_name.rb | 18 - ...20240719143453_llm_model_vision_enabled.rb | 6 - ...0240726164937_fix_ai_summaries_sequence.rb | 38 - ...07150605_add_default_to_provider_params.rb | 6 - ...240909180908_add_ai_summary_type_column.rb | 6 - ...713_add_target_to_rag_document_fragment.rb | 9 - ...40913054440_add_rag_columns_to_ai_tools.rb | 8 - ...40_create_binary_indexes_for_embeddings.rb | 27 - ...24_add_forced_tool_count_to_ai_personas.rb | 7 - ...14010245_ai_persona_chat_topic_refactor.rb | 23 - ...0241020010245_add_tool_name_to_ai_tools.rb | 23 - ...33955_add_feature_context_to_ai_api_log.rb | 7 - ...41242_add_unique_constraint_to_ai_tools.rb | 25 - .../20241025135522_alter_ai_ids_to_bigint.rb | 13 - ...ue_ai_stream_conversation_user_id_index.rb | 9 - .../20241031145203_track_ai_summary_origin.rb | 6 - ...44_set_origin_for_existing_ai_summaries.rb | 14 - db/migrate/20241104053017_add_ai_artifacts.rb | 16 - .../20241125132452_unique_ai_summaries.rb | 19 - ...1126033812_rename_ai_gist_batch_setting.rb | 11 - ...1_add_cached_tokens_to_ai_api_audit_log.rb | 9 - .../20241129190708_fix_classification_data.rb | 37 - .../20241130003808_add_artifact_versions.rb | 17 - ...241206030229_add_ai_moderation_settings.rb | 15 - db/migrate/20241206051225_add_ai_spam_logs.rb | 16 - ...1217164540_create_embedding_definitions.rb | 19 - .../20241230153300_new_embeddings_tables.rb | 73 - .../20250102035341_add_llm_quota_tables.rb | 31 - ...0114305_embedding_config_data_migration.rb | 204 - ...0250114160417_backfill_topic_embeddings.rb | 32 - ...20250114160446_backfill_post_embeddings.rb | 34 - .../20250114160500_backfill_rag_embeddings.rb | 21 - ...add_highest_target_number_to_ai_summary.rb | 14 - ...162520_configurable_embeddings_prefixes.rb | 18 - ...250122003035_add_duration_to_ai_api_log.rb | 6 - ...122131007_matryoshka_dimensions_support.rb | 22 - ...58_fix_broken_open_ai_embeddings_config.rb | 107 - ...5_clean_unused_embedding_search_indexes.rb | 61 - .../20250210024600_add_rag_llm_model.rb | 7 - ...0032345_migrate_persona_to_llm_model_id.rb | 19 - ...20250211021037_add_error_to_ai_spam_log.rb | 6 - ...d_ai_search_discoveries_to_user_options.rb | 7 - ...ct_default_for_short_summarizer_persona.rb | 14 - ...5_add_response_format_json_to_personass.rb | 6 - ...416215039_add_cost_metrics_to_llm_model.rb | 9 - ...4503_add_max_output_tokens_to_llm_model.rb | 7 - .../20250424035234_remove_old_settings.rb | 13 - ...50424163718_set_ai_bot_pm_custom_fields.rb | 39 - db/migrate/20250429060311_move_dall_e_url.rb | 23 - ...657_renamed_experimental_ai_bot_setting.rb | 10 - ...20250508154953_add_examples_to_personas.rb | 7 - ...08182047_create_inferred_concepts_table.rb | 11 - ...08183456_create_inferred_concept_topics.rb | 18 - ...509000001_create_inferred_concept_posts.rb | 18 - ...07071239_create_ai_artifacts_key_values.rb | 18 - ...5_add_persona_to_ai_moderation_settings.rb | 6 - ...cify_rate_frequency_in_backfill_setting.rb | 11 - ..._mixtral_tokenizer_to_mistral_tokenizer.rb | 43 - ...701_update_open_ai_embeddings_tokenizer.rb | 14 - ...075002_set_translation_backfill_max_age.rb | 22 - ...21192553_enable_ai_if_already_installed.rb | 23 - ...82515_add_index_to_ai_topics_embeddings.rb | 11 - .../20240724174343_migrate_vision_llms.rb | 45 - ...0729202857_migrate_persona_llm_override.rb | 46 - ...162837_rename_ai_helper_enabled_setting.rb | 11 - ...rename_ai_helper_allowed_groups_setting.rb | 11 - ..._persona_id_from_rag_document_fragments.rb | 22 - ...41008055831_drop_old_embeddings_indexes.rb | 37 - ...41242_ai_persona_post_migrate_drop_cols.rb | 7 - ..._sentiment_classification_result_format.rb | 34 - ..._rebake_shared_ai_conversation_oneboxes.rb | 15 - ...0250113171444_drop_old_embedding_tables.rb | 10 - ...250114184356_drop_old_embedding_tables2.rb | 55 - ...5181147_drop_ai_summaries_content_range.rb | 12 - ...51_post_migrate_persona_to_llm_model_id.rb | 11 - ...0404045050_migrate_users_to_email_group.rb | 24 - discourse_automation/llm_persona_triage.rb | 58 - discourse_automation/llm_report.rb | 134 - discourse_automation/llm_tool_triage.rb | 49 - discourse_automation/llm_triage.rb | 140 - eslint.config.mjs | 3 - evals/lib/boot.rb | 36 - evals/lib/cli.rb | 47 - evals/lib/eval.rb | 329 -- evals/lib/llm.rb | 82 - evals/lib/prompts/prompt_evaluator.rb | 84 - evals/lib/prompts/single_test_runner.rb | 76 - evals/lib/runner.rb | 194 - evals/run | 27 - lib/ai_bot/chat_streamer.rb | 129 - lib/ai_bot/entry_point.rb | 294 -- lib/ai_bot/playground.rb | 661 --- lib/ai_bot/post_streamer.rb | 68 - lib/ai_bot/response_http_streamer.rb | 130 - lib/ai_bot/site_settings_extension.rb | 7 - lib/ai_helper/assistant.rb | 469 -- lib/ai_helper/chat_thread_titler.rb | 62 - lib/ai_helper/date_formatter.rb | 144 - lib/ai_helper/entry_point.rb | 79 - lib/ai_helper/painter.rb | 77 - lib/ai_helper/semantic_categorizer.rb | 145 - lib/ai_moderation/entry_point.rb | 42 - lib/ai_moderation/spam_metric.rb | 30 - lib/ai_moderation/spam_report.rb | 48 - lib/ai_moderation/spam_scanner.rb | 466 -- lib/automation.rb | 58 - lib/automation/llm_persona_triage.rb | 23 - lib/automation/llm_tool_triage.rb | 17 - lib/automation/llm_triage.rb | 168 - lib/automation/report_context_generator.rb | 246 - lib/automation/report_runner.rb | 270 -- .../anthropic_message_processor.rb | 178 - lib/completions/cancel_manager.rb | 109 - lib/completions/dialects/chat_gpt.rb | 178 - lib/completions/dialects/claude.rb | 169 - lib/completions/dialects/claude_tools.rb | 64 - lib/completions/dialects/cohere_tools.rb | 91 - lib/completions/dialects/command.rb | 121 - lib/completions/dialects/dialect.rb | 266 -- lib/completions/dialects/fake.rb | 23 - lib/completions/dialects/gemini.rb | 163 - lib/completions/dialects/mistral.rb | 44 - lib/completions/dialects/nova.rb | 178 - lib/completions/dialects/nova_tools.rb | 57 - lib/completions/dialects/ollama.rb | 97 - lib/completions/dialects/ollama_tools.rb | 50 - .../dialects/open_ai_compatible.rb | 49 - lib/completions/dialects/open_ai_tools.rb | 84 - lib/completions/dialects/xml_tools.rb | 149 - lib/completions/endpoints/anthropic.rb | 180 - lib/completions/endpoints/aws_bedrock.rb | 266 -- lib/completions/endpoints/base.rb | 473 -- lib/completions/endpoints/canned_response.rb | 102 - lib/completions/endpoints/cohere.rb | 158 - lib/completions/endpoints/fake.rb | 175 - lib/completions/endpoints/gemini.rb | 257 -- lib/completions/endpoints/hugging_face.rb | 92 - lib/completions/endpoints/mistral.rb | 17 - lib/completions/endpoints/ollama.rb | 94 - lib/completions/endpoints/open_ai.rb | 193 - lib/completions/endpoints/open_router.rb | 56 - lib/completions/endpoints/samba_nova.rb | 93 - lib/completions/endpoints/vllm.rb | 102 - lib/completions/json_stream_decoder.rb | 48 - lib/completions/json_streaming_parser.rb | 668 --- lib/completions/json_streaming_tracker.rb | 89 - lib/completions/llm.rb | 438 -- lib/completions/nova_message_processor.rb | 94 - lib/completions/open_ai_message_processor.rb | 128 - .../open_ai_responses_message_processor.rb | 160 - lib/completions/prompt.rb | 235 - lib/completions/prompt_messages_builder.rb | 525 --- lib/completions/report.rb | 204 - lib/completions/structured_output.rb | 86 - lib/completions/thinking.rb | 38 - lib/completions/tool_call.rb | 41 - lib/completions/tool_definition.rb | 252 -- lib/completions/upload_encoder.rb | 56 - lib/completions/xml_tag_stripper.rb | 121 - lib/completions/xml_tool_processor.rb | 221 - .../embedding_defs_enumerator.rb | 20 - lib/configuration/embedding_defs_validator.rb | 19 - .../embeddings_model_validator.rb | 43 - .../embeddings_module_validator.rb | 22 - lib/configuration/feature.rb | 296 -- lib/configuration/llm_dependency_validator.rb | 37 - lib/configuration/llm_enumerator.rb | 115 - lib/configuration/llm_validator.rb | 92 - lib/configuration/llm_vision_enumerator.rb | 25 - lib/configuration/module.rb | 128 - lib/configuration/persona_enumerator.rb | 19 - lib/configuration/spam_detection_validator.rb | 23 - lib/database/connection.rb | 12 - lib/discord/bot/base.rb | 42 - lib/discord/bot/persona_replier.rb | 55 - lib/discord/bot/search.rb | 36 - lib/embeddings.rb | 10 - lib/embeddings/entry_point.rb | 78 - lib/embeddings/schema.rb | 288 -- lib/embeddings/semantic_related.rb | 102 - lib/embeddings/semantic_search.rb | 249 - lib/embeddings/semantic_topic_query.rb | 28 - lib/embeddings/strategies/truncation.rb | 98 - lib/embeddings/vector.rb | 82 - lib/engine.rb | 8 - lib/guardian_extensions.rb | 104 - lib/inference/cloudflare_workers_ai.rb | 38 - lib/inference/discourse_reranker.rb | 25 - lib/inference/gemini_embeddings.rb | 34 - lib/inference/hugging_face_text_embeddings.rb | 82 - lib/inference/open_ai_embeddings.rb | 42 - lib/inference/open_ai_image_generator.rb | 469 -- lib/inference/stability_generator.rb | 160 - lib/inferred_concepts/applier.rb | 135 - lib/inferred_concepts/finder.rb | 176 - lib/inferred_concepts/manager.rb | 201 - lib/multisite_hash.rb | 39 - .../artifact_update_strategies/base.rb | 68 - .../artifact_update_strategies/diff.rb | 300 -- .../artifact_update_strategies/full.rb | 150 - lib/personas/artist.rb | 36 - lib/personas/bot.rb | 346 -- lib/personas/bot_context.rb | 166 - lib/personas/concept_deduplicator.rb | 53 - lib/personas/concept_finder.rb | 49 - lib/personas/concept_matcher.rb | 43 - lib/personas/content_creator.rb | 33 - lib/personas/creative.rb | 17 - lib/personas/custom_prompt.rb | 30 - lib/personas/dall_e_3.rb | 37 - lib/personas/designer.rb | 28 - lib/personas/discourse_helper.rb | 46 - lib/personas/forum_researcher.rb | 58 - lib/personas/general.rb | 32 - lib/personas/github_helper.rb | 29 - lib/personas/image_captioner.rb | 29 - lib/personas/locale_detector.rb | 55 - lib/personas/markdown_table_generator.rb | 67 - lib/personas/persona.rb | 456 -- lib/personas/post_illustrator.rb | 30 - lib/personas/post_raw_translator.rb | 81 - lib/personas/proofreader.rb | 80 - lib/personas/question_consolidator.rb | 98 - lib/personas/report_runner.rb | 25 - lib/personas/researcher.rb | 52 - lib/personas/settings_explorer.rb | 24 - lib/personas/short_summarizer.rb | 40 - lib/personas/short_text_translator.rb | 56 - lib/personas/smart_dates.rb | 71 - lib/personas/spam_detector.rb | 62 - lib/personas/sql_helper.rb | 104 - lib/personas/summarizer.rb | 53 - lib/personas/titles_generator.rb | 62 - lib/personas/tool_runner.rb | 1009 ----- lib/personas/tools/create_artifact.rb | 378 -- lib/personas/tools/create_image.rb | 87 - lib/personas/tools/custom.rb | 113 - lib/personas/tools/dall_e.rb | 97 - lib/personas/tools/db_schema.rb | 62 - lib/personas/tools/discourse_meta_search.rb | 192 - lib/personas/tools/edit_image.rb | 88 - lib/personas/tools/github_file_content.rb | 102 - .../tools/github_pull_request_diff.rb | 155 - lib/personas/tools/github_search_code.rb | 82 - lib/personas/tools/github_search_files.rb | 101 - lib/personas/tools/google.rb | 111 - lib/personas/tools/image.rb | 167 - lib/personas/tools/javascript_evaluator.rb | 106 - lib/personas/tools/list_categories.rb | 46 - lib/personas/tools/list_tags.rb | 41 - lib/personas/tools/option.rb | 27 - lib/personas/tools/random_picker.rb | 73 - lib/personas/tools/read.rb | 106 - lib/personas/tools/read_artifact.rb | 211 - lib/personas/tools/researcher.rb | 265 -- lib/personas/tools/search.rb | 167 - lib/personas/tools/search_settings.rb | 103 - lib/personas/tools/setting_context.rb | 165 - lib/personas/tools/summarize.rb | 191 - lib/personas/tools/time.rb | 52 - lib/personas/tools/tool.rb | 301 -- lib/personas/tools/update_artifact.rb | 261 -- lib/personas/tools/web_browser.rb | 109 - lib/personas/topic_title_translator.rb | 76 - lib/personas/translator.rb | 40 - lib/personas/tutor.rb | 38 - lib/personas/web_artifact_creator.rb | 56 - lib/post_extensions.rb | 19 - lib/sentiment/constants.rb | 9 - lib/sentiment/emotion_dashboard_report.rb | 48 - lib/sentiment/emotion_filter_order.rb | 68 - lib/sentiment/emotions.rb | 38 - lib/sentiment/entry_point.rb | 28 - lib/sentiment/post_classification.rb | 205 - lib/sentiment/sentiment_analysis_report.rb | 182 - lib/sentiment/sentiment_dashboard_report.rb | 60 - .../sentiment_site_setting_json_schema.rb | 35 - lib/summarization.rb | 80 - lib/summarization/entry_point.rb | 56 - lib/summarization/fold_content.rb | 174 - lib/summarization/strategies/base.rb | 48 - lib/summarization/strategies/chat_messages.rb | 54 - .../strategies/hot_topic_gists.rb | 97 - lib/summarization/strategies/topic_summary.rb | 93 - lib/tasks/create_topics.rake | 101 - lib/tasks/modules/ai_moderation/scan.rake | 29 - lib/tasks/modules/embeddings/database.rake | 42 - lib/tasks/modules/sentiment/backfill.rake | 11 - lib/tasks/modules/sentiment/populate.rake | 62 - lib/tasks/modules/toxicity/calibration.rake | 77 - lib/topic_extensions.rb | 19 - lib/translation.rb | 16 - lib/translation/base_translator.rb | 78 - lib/translation/category_locale_detector.rb | 19 - lib/translation/category_localizer.rb | 32 - lib/translation/content_splitter.rb | 113 - lib/translation/entry_point.rb | 27 - lib/translation/language_detector.rb | 54 - lib/translation/post_detection_text.rb | 40 - lib/translation/post_locale_detector.rb | 17 - lib/translation/post_localizer.rb | 28 - lib/translation/post_raw_translator.rb | 13 - lib/translation/short_text_translator.rb | 13 - lib/translation/topic_locale_detector.rb | 16 - lib/translation/topic_localizer.rb | 28 - lib/translation/topic_title_translator.rb | 13 - lib/translation/verbose_logger.rb | 13 - lib/utils/ai_staff_action_logger.rb | 206 - lib/utils/best_effort_json_parser.rb | 137 - lib/utils/diff_utils/hunk_diff.rb | 228 - lib/utils/diff_utils/safety_checker.rb | 91 - lib/utils/diff_utils/simple_diff.rb | 214 - lib/utils/dns_srv.rb | 65 - lib/utils/image_to_text.rb | 232 - lib/utils/pdf_to_images.rb | 79 - lib/utils/pdf_to_text.rb | 112 - lib/utils/research/filter.rb | 395 -- lib/utils/research/llm_formatter.rb | 205 - lib/utils/search.rb | 176 - package.json | 17 - plugin.rb | 160 - pnpm-lock.yaml | 4021 ----------------- public/ai-share/highlight.min.js | 1120 ----- public/ai-share/share.css | 640 --- spec/configuration/feature_spec.rb | 226 - spec/configuration/llm_enumerator_spec.rb | 68 - spec/configuration/llm_validator_spec.rb | 23 - .../spam_detection_validator_spec.rb | 32 - ...iment_classification_result_format_spec.rb | 55 - ...x_broken_open_ai_embeddings_config_spec.rb | 75 - ...an_unused_embedding_search_indexes_spec.rb | 138 - spec/fabricators/ai_artifact_fabricator.rb | 27 - spec/fabricators/ai_persona_fabricator.rb | 6 - spec/fabricators/ai_summary_fabricator.rb | 17 - spec/fabricators/ai_tool_fabricator.rb | 10 - .../classification_result_fabricator.rb | 18 - .../embedding_definition_fabricator.rb | 40 - .../inferred_concept_fabricator.rb | 2 - spec/fabricators/llm_model_fabricator.rb | 135 - spec/fabricators/llm_quota_fabricator.rb | 9 - .../fabricators/llm_quota_usage_fabricator.rb | 11 - .../rag_document_fragment_fabricator.rb | 7 - spec/fixtures/bot/openai_artifact_call.txt | 299 -- spec/fixtures/embeddings/embedding.txt | 1 - spec/fixtures/images/100x100.jpg | Bin 415 -> 0 bytes spec/fixtures/images/1x1.gif | Bin 43 -> 0 bytes spec/fixtures/images/1x1.jpg | Bin 160 -> 0 bytes spec/fixtures/images/1x1.webp | Bin 44 -> 0 bytes .../images/An image of discobot in action.png | Bin 14859 -> 0 bytes spec/fixtures/rag/2-page.pdf | Bin 13529 -> 0 bytes spec/fixtures/rag/doc_with_metadata.txt | 52 - .../fixtures/rag/parsed_doc_with_metadata.txt | 122 - spec/fixtures/search_meta/search.json | 1 - .../search_meta/search_with_categories.json | 1 - spec/fixtures/search_meta/site.json | 1 - .../regular/detect_translate_post_spec.rb | 174 - .../regular/detect_translate_topic_spec.rb | 176 - spec/jobs/regular/digest_rag_upload_spec.rb | 118 - .../regular/fast_track_topic_gist_spec.rb | 89 - .../generate_inferred_concepts_spec.rb | 170 - .../regular/generate_rag_embeddings_spec.rb | 57 - spec/jobs/regular/localize_categories_spec.rb | 174 - spec/jobs/regular/localize_posts_spec.rb | 231 - spec/jobs/regular/localize_topics_spec.rb | 233 - .../manage_embedding_def_search_index_spec.rb | 58 - .../regular/stream_composer_helper_spec.rb | 108 - .../jobs/regular/stream_discord_reply_spec.rb | 38 - .../regular/stream_discover_reply_spec.rb | 54 - spec/jobs/regular/stream_post_helper_spec.rb | 164 - .../regular/stream_topic_ai_summary_spec.rb | 92 - ...tegories_locale_detection_backfill_spec.rb | 121 - .../scheduled/embeddings_backfill_spec.rb | 88 - ...nerate_concepts_from_popular_items_spec.rb | 260 -- .../post_localization_backfill_spec.rb | 55 - .../posts_locale_detection_backfill_spec.rb | 153 - .../remove_orphaned_embeddings_spec.rb | 73 - .../jobs/scheduled/sentiment_backfill_spec.rb | 54 - .../jobs/scheduled/summaries_backfill_spec.rb | 134 - .../topics_locale_detection_backfill_spec.rb | 161 - ...onversation_adjust_upload_security_spec.rb | 72 - .../anthropic_message_processor_spec.rb | 75 - spec/lib/completions/cancel_manager_spec.rb | 111 - .../lib/completions/dialects/chat_gpt_spec.rb | 129 - spec/lib/completions/dialects/claude_spec.rb | 141 - .../completions/dialects/dialect_context.rb | 120 - spec/lib/completions/dialects/dialect_spec.rb | 121 - spec/lib/completions/dialects/gemini_spec.rb | 129 - spec/lib/completions/dialects/mistral_spec.rb | 83 - spec/lib/completions/dialects/nova_spec.rb | 197 - spec/lib/completions/dialects/ollama_spec.rb | 111 - .../completions/dialects/ollama_tools_spec.rb | 116 - .../dialects/open_ai_compatible_spec.rb | 97 - .../completions/endpoints/anthropic_spec.rb | 959 ---- .../completions/endpoints/aws_bedrock_spec.rb | 760 ---- spec/lib/completions/endpoints/cohere_spec.rb | 374 -- .../endpoints/endpoint_compliance.rb | 232 - spec/lib/completions/endpoints/gemini_spec.rb | 675 --- .../endpoints/hugging_face_spec.rb | 117 - spec/lib/completions/endpoints/nova_spec.rb | 288 -- spec/lib/completions/endpoints/ollama_spec.rb | 161 - .../endpoints/open_ai_responses_api_spec.rb | 242 - .../lib/completions/endpoints/open_ai_spec.rb | 965 ---- .../completions/endpoints/open_router_spec.rb | 80 - .../completions/endpoints/samba_nova_spec.rb | 54 - spec/lib/completions/endpoints/vllm_spec.rb | 277 -- .../completions/json_stream_decoder_spec.rb | 49 - spec/lib/completions/llm_spec.rb | 195 - .../prompt_messages_builder_spec.rb | 590 --- spec/lib/completions/prompt_spec.rb | 88 - .../lib/completions/structured_output_spec.rb | 159 - spec/lib/completions/tool_definition_spec.rb | 340 -- spec/lib/completions/upload_encoder_spec.rb | 33 - spec/lib/completions/xml_tag_stripper_spec.rb | 79 - .../completions/xml_tool_processor_spec.rb | 277 -- spec/lib/discord/bot/persona_replier_spec.rb | 29 - spec/lib/discord/bot/search_spec.rb | 32 - .../discourse_automation/automation_spec.rb | 63 - .../llm_persona_triage_spec.rb | 360 -- .../discourse_automation/llm_report_spec.rb | 60 - .../llm_tool_triage_spec.rb | 113 - .../discourse_automation/llm_triage_spec.rb | 210 - spec/lib/guardian_extensions_spec.rb | 101 - .../inference/cloudflare_workers_ai_spec.rb | 58 - spec/lib/inferred_concepts/applier_spec.rb | 334 -- spec/lib/inferred_concepts/finder_spec.rb | 282 -- spec/lib/inferred_concepts/manager_spec.rb | 241 - spec/lib/modules/ai_bot/entry_point_spec.rb | 190 - .../jobs/regular/create_ai_reply_spec.rb | 37 - spec/lib/modules/ai_bot/playground_spec.rb | 1229 ----- .../ai_bot/site_setting_extension_spec.rb | 74 - spec/lib/modules/ai_helper/assistant_spec.rb | 229 - .../ai_helper/chat_thread_titler_spec.rb | 86 - .../modules/ai_helper/date_formatter_spec.rb | 262 -- .../lib/modules/ai_helper/entry_point_spec.rb | 52 - spec/lib/modules/ai_helper/painter_spec.rb | 89 - .../ai_helper/semantic_categorizer_spec.rb | 42 - .../ai_moderation/spam_scanner_spec.rb | 378 -- .../lib/modules/automation/llm_triage_spec.rb | 265 -- .../report_context_generator_spec.rb | 157 - .../modules/automation/report_runner_spec.rb | 271 -- .../modules/embeddings/entry_point_spec.rb | 42 - .../jobs/generate_embeddings_spec.rb | 45 - spec/lib/modules/embeddings/schema_spec.rb | 104 - .../embeddings/semantic_related_spec.rb | 96 - .../embeddings/semantic_search_spec.rb | 137 - .../embeddings/semantic_topic_query_spec.rb | 150 - .../embeddings/strategies/truncation_spec.rb | 56 - spec/lib/modules/embeddings/vector_spec.rb | 184 - .../sentiment/emotion_filter_order_spec.rb | 205 - .../lib/modules/sentiment/entry_point_spec.rb | 178 - .../regular/post_sentiment_analysis_spec.rb | 56 - .../sentiment/post_classification_spec.rb | 213 - .../modules/summarization/entry_point_spec.rb | 158 - .../summarization/fold_content_spec.rb | 76 - .../strategies/hot_topic_gists_spec.rb | 62 - .../strategies/topic_summary_spec.rb | 99 - .../artifact_update_strategies/diff_spec.rb | 244 - spec/lib/personas/bot_spec.rb | 91 - .../lib/personas/concept_deduplicator_spec.rb | 34 - spec/lib/personas/concept_finder_spec.rb | 65 - spec/lib/personas/concept_matcher_spec.rb | 38 - spec/lib/personas/persona_spec.rb | 507 --- .../personas/question_consolidator_spec.rb | 35 - spec/lib/personas/researcher_spec.rb | 13 - spec/lib/personas/settings_explorer_spec.rb | 20 - spec/lib/personas/sql_helper_spec.rb | 17 - .../personas/tools/create_artifact_spec.rb | 79 - spec/lib/personas/tools/create_image_spec.rb | 139 - spec/lib/personas/tools/dall_e_spec.rb | 97 - spec/lib/personas/tools/db_schema_spec.rb | 25 - .../tools/discourse_meta_search_spec.rb | 86 - spec/lib/personas/tools/edit_image_spec.rb | 110 - .../tools/github_file_content_spec.rb | 83 - .../tools/github_pull_request_diff_spec.rb | 150 - .../personas/tools/github_search_code_spec.rb | 96 - .../tools/github_search_files_spec.rb | 94 - spec/lib/personas/tools/google_spec.rb | 94 - spec/lib/personas/tools/image_spec.rb | 61 - .../tools/javascript_evaluator_spec.rb | 75 - .../personas/tools/list_categories_spec.rb | 23 - spec/lib/personas/tools/list_tags_spec.rb | 25 - spec/lib/personas/tools/random_picker_spec.rb | 59 - spec/lib/personas/tools/read_artifact_spec.rb | 136 - spec/lib/personas/tools/read_spec.rb | 107 - spec/lib/personas/tools/researcher_spec.rb | 187 - .../personas/tools/search_settings_spec.rb | 67 - spec/lib/personas/tools/search_spec.rb | 221 - .../personas/tools/setting_context_spec.rb | 60 - spec/lib/personas/tools/summarize_spec.rb | 58 - spec/lib/personas/tools/time_spec.rb | 24 - spec/lib/personas/tools/tool_spec.rb | 46 - .../personas/tools/update_artifact_spec.rb | 362 -- spec/lib/personas/tools/web_browser_spec.rb | 99 - spec/lib/translation/base_translator_spec.rb | 106 - .../category_locale_detector_spec.rb | 56 - .../translation/category_localizer_spec.rb | 105 - spec/lib/translation/content_splitter_spec.rb | 96 - spec/lib/translation/entry_point_spec.rb | 88 - .../lib/translation/language_detector_spec.rb | 61 - .../translation/post_detection_text_spec.rb | 79 - .../translation/post_locale_detector_spec.rb | 37 - spec/lib/translation/post_localizer_spec.rb | 91 - .../translation/topic_locale_detector_spec.rb | 41 - spec/lib/translation/topic_localizer_spec.rb | 131 - spec/lib/utils/ai_staff_action_logger_spec.rb | 481 -- .../lib/utils/best_effort_json_parser_spec.rb | 192 - spec/lib/utils/diff_utils/hunk_diff_spec.rb | 286 -- .../utils/diff_utils/safety_checker_spec.rb | 82 - spec/lib/utils/diff_utils/simple_diff_spec.rb | 205 - spec/lib/utils/dns_srv_spec.rb | 88 - spec/lib/utils/pdf_to_text_spec.rb | 66 - spec/lib/utils/research/filter_spec.rb | 310 -- spec/lib/utils/research/llm_formatter_spec.rb | 76 - spec/lib/utils/search_spec.rb | 220 - spec/models/ai_artifact_key_value_spec.rb | 35 - spec/models/ai_persona_multisite_spec.rb | 26 - spec/models/ai_persona_spec.rb | 325 -- spec/models/ai_tool_spec.rb | 1181 ----- spec/models/inferred_concept_spec.rb | 63 - spec/models/llm_model_spec.rb | 15 - spec/models/llm_quota_spec.rb | 132 - spec/models/llm_quota_usage_spec.rb | 252 -- spec/models/model_accuracy_spec.rb | 80 - spec/models/rag_document_fragment_spec.rb | 131 - .../models/reviewable_ai_chat_message_spec.rb | 79 - spec/models/reviewable_ai_post_spec.rb | 243 - spec/models/shared_ai_conversation_spec.rb | 122 - spec/models/user_option_spec.rb | 39 - spec/plugin_helper.rb | 26 - spec/plugin_spec.rb | 34 - spec/reports/sentiment_analysis_spec.rb | 25 - .../admin/ai_embeddings_controller_spec.rb | 262 -- .../admin/ai_features_controller_spec.rb | 32 - .../admin/ai_llm_quotas_controller_spec.rb | 108 - .../requests/admin/ai_llms_controller_spec.rb | 538 --- .../admin/ai_personas_controller_spec.rb | 983 ---- .../requests/admin/ai_spam_controller_spec.rb | 498 -- .../admin/ai_tools_controller_spec.rb | 403 -- .../admin/ai_usage_controller_spec.rb | 181 - .../rag_document_fragments_controller_spec.rb | 72 - .../admin/reviewable_controller_spec.rb | 50 - .../artifact_key_values_controller_spec.rb | 436 -- .../ai_bot/artifacts_controller_spec.rb | 91 - spec/requests/ai_bot/bot_controller_spec.rb | 238 - ...shared_ai_conversations_controller_spec.rb | 377 -- .../ai_bot/topic_serialization_spec.rb | 38 - .../ai_helper/assistant_controller_spec.rb | 495 -- spec/requests/discord/bot_controller_spec.rb | 64 - .../embeddings/embeddings_controller_spec.rb | 139 - .../sentiment/sentiment_controller_spec.rb | 39 - .../chat_summary_controller_spec.rb | 32 - .../summarization/summary_controller_spec.rb | 135 - spec/requests/topic_spec.rb | 71 - .../ai_chat_channel_serializer_spec.rb | 29 - .../ai_features_persona_serializer_spec.rb | 33 - .../discourse_ai/persona_exporter_spec.rb | 43 - .../discourse_ai/persona_importer_spec.rb | 158 - .../discourse_ai/topic_summarization_spec.rb | 133 - .../problem_check/ai_llm_status_spec.rb | 75 - .../inference/openai_embeddings_spec.rb | 63 - .../inference/stability_generator_spec.rb | 81 - spec/support/embeddings_generation_stubs.rb | 36 - spec/support/sentiment_inference_stubs.rb | 74 - spec/support/stable_diffusion_stubs.rb | 21 - spec/system/admin_ai_features_spec.rb | 60 - spec/system/admin_ai_persona_spec.rb | 122 - spec/system/admin_dashboard_spec.rb | 27 - spec/system/ai_artifact_key_value_api_spec.rb | 63 - spec/system/ai_bot/ai_bot_helper_spec.rb | 61 - .../ai_bot/ai_share_conversation_spec.rb | 32 - spec/system/ai_bot/artifact_spec.rb | 75 - spec/system/ai_bot/homepage_spec.rb | 456 -- spec/system/ai_bot/persona_spec.rb | 59 - spec/system/ai_bot/share_spec.rb | 157 - spec/system/ai_bot/tool_spec.rb | 78 - .../ai_helper/ai_composer_helper_spec.rb | 432 -- .../system/ai_helper/ai_image_caption_spec.rb | 161 - spec/system/ai_helper/ai_post_helper_spec.rb | 188 - spec/system/ai_helper/ai_proofreading_spec.rb | 96 - .../ai_split_topic_suggestion_spec.rb | 144 - spec/system/ai_moderation/ai_spam_spec.rb | 48 - spec/system/ai_user_preferences_spec.rb | 80 - spec/system/core_features_spec.rb | 7 - .../ai_embedding_definition_spec.rb | 87 - .../system/embeddings/semantic_search_spec.rb | 47 - spec/system/llms/ai_llm_spec.rb | 201 - .../components/ai_caption_popup.rb | 43 - .../components/ai_composer_helper_menu.rb | 78 - .../page_objects/components/ai_pm_homepage.rb | 78 - .../components/ai_post_helper_menu.rb | 93 - .../components/ai_split_topic_suggester.rb | 55 - .../components/ai_suggestion_dropdown.rb | 54 - .../page_objects/components/ai_summary_box.rb | 26 - .../page_objects/modals/ai_tool_test_modal.rb | 26 - spec/system/page_objects/modals/diff_modal.rb | 32 - .../page_objects/pages/admin_ai_features.rb | 49 - .../page_objects/pages/discourse_ai/header.rb | 17 - .../page_objects/pages/user_preferences_ai.rb | 32 - .../summarization/chat_summarization_spec.rb | 41 - .../summarization/topic_summarization_spec.rb | 60 - spec/tasks/backfill_spec.rb | 26 - spec/tasks/scan_spec.rb | 40 - stylelint.config.mjs | 3 - svg-icons/icons-sprite.svg | 20 - test/javascripts/.gitkeep | 0 .../acceptance/post-helper-menu-test.js | 112 - .../acceptance/topic-summary-test.js | 155 - .../javascripts/fixtures/ai-helper-prompts.js | 51 - .../components/ai-indicator-wave-test.gjs | 18 - test/javascripts/unit/lib/ai-streamer-test.js | 168 - .../unit/lib/diff-streamer-test.js | 67 - .../unit/lib/smooth-streamer-test.js | 106 - test/javascripts/unit/lib/utilities-test.js | 65 - .../unit/models/ai-persona-test.js | 74 - ...-ai-shared-conversation-show-route-test.js | 61 - translator.yml | 7 - 1110 files changed, 2 insertions(+), 133260 deletions(-) delete mode 100644 Gemfile delete mode 100644 Gemfile.lock delete mode 100644 LICENSE delete mode 100644 about.json delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-edit.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-new.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-edit.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-new.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-edit.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-new.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-spam.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-edit.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-new.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools.js delete mode 100644 admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-usage.js delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/edit.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/index.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/new.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/index.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/edit.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/index.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/new.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/edit.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/index.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/new.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-spam.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/edit.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/index.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/new.hbs delete mode 100644 admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-usage.hbs delete mode 100644 app/controllers/discourse_ai/admin/ai_embeddings_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/ai_features_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/ai_llm_quotas_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/ai_llms_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/ai_personas_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/ai_spam_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/ai_tools_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/ai_usage_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/dashboard_controller.rb delete mode 100644 app/controllers/discourse_ai/admin/rag_document_fragments_controller.rb delete mode 100644 app/controllers/discourse_ai/ai_bot/artifact_key_values_controller.rb delete mode 100644 app/controllers/discourse_ai/ai_bot/artifacts_controller.rb delete mode 100644 app/controllers/discourse_ai/ai_bot/bot_controller.rb delete mode 100644 app/controllers/discourse_ai/ai_bot/conversations_controller.rb delete mode 100644 app/controllers/discourse_ai/ai_bot/shared_ai_conversations_controller.rb delete mode 100644 app/controllers/discourse_ai/ai_helper/assistant_controller.rb delete mode 100644 app/controllers/discourse_ai/discord/bot_controller.rb delete mode 100644 app/controllers/discourse_ai/embeddings/embeddings_controller.rb delete mode 100644 app/controllers/discourse_ai/sentiment/sentiment_controller.rb delete mode 100644 app/controllers/discourse_ai/summarization/chat_summary_controller.rb delete mode 100644 app/controllers/discourse_ai/summarization/summary_controller.rb delete mode 100644 app/helpers/discourse_ai/ai_bot/shared_ai_conversations_helper.rb delete mode 100644 app/jobs/regular/ai_spam_scan.rb delete mode 100644 app/jobs/regular/create_ai_chat_reply.rb delete mode 100644 app/jobs/regular/create_ai_reply.rb delete mode 100644 app/jobs/regular/detect_translate_post.rb delete mode 100644 app/jobs/regular/detect_translate_topic.rb delete mode 100644 app/jobs/regular/digest_rag_upload.rb delete mode 100644 app/jobs/regular/fast_track_topic_gist.rb delete mode 100644 app/jobs/regular/generate_chat_thread_title.rb delete mode 100644 app/jobs/regular/generate_embeddings.rb delete mode 100644 app/jobs/regular/generate_inferred_concepts.rb delete mode 100644 app/jobs/regular/generate_rag_embeddings.rb delete mode 100644 app/jobs/regular/localize_categories.rb delete mode 100644 app/jobs/regular/localize_posts.rb delete mode 100644 app/jobs/regular/localize_topics.rb delete mode 100644 app/jobs/regular/manage_embedding_def_search_index.rb delete mode 100644 app/jobs/regular/post_sentiment_analysis.rb delete mode 100644 app/jobs/regular/shared_conversation_adjust_upload_security.rb delete mode 100644 app/jobs/regular/stream_composer_helper.rb delete mode 100644 app/jobs/regular/stream_discord_reply.rb delete mode 100644 app/jobs/regular/stream_discover_reply.rb delete mode 100644 app/jobs/regular/stream_post_helper.rb delete mode 100644 app/jobs/regular/stream_topic_ai_summary.rb delete mode 100644 app/jobs/scheduled/categories_locale_detection_backfill.rb delete mode 100644 app/jobs/scheduled/category_localization_backfill.rb delete mode 100644 app/jobs/scheduled/embeddings_backfill.rb delete mode 100644 app/jobs/scheduled/generate_concepts_from_popular_items.rb delete mode 100644 app/jobs/scheduled/post_localization_backfill.rb delete mode 100644 app/jobs/scheduled/posts_locale_detection_backfill.rb delete mode 100644 app/jobs/scheduled/remove_orphaned_embeddings.rb delete mode 100644 app/jobs/scheduled/sentiment_backfill.rb delete mode 100644 app/jobs/scheduled/summaries_backfill.rb delete mode 100644 app/jobs/scheduled/topic_localization_backfill.rb delete mode 100644 app/jobs/scheduled/topics_locale_detection_backfill.rb delete mode 100644 app/mailers/ai_report_mailer.rb delete mode 100644 app/models/ai_api_audit_log.rb delete mode 100644 app/models/ai_artifact.rb delete mode 100644 app/models/ai_artifact_key_value.rb delete mode 100644 app/models/ai_artifact_version.rb delete mode 100644 app/models/ai_moderation_setting.rb delete mode 100644 app/models/ai_persona.rb delete mode 100644 app/models/ai_spam_log.rb delete mode 100644 app/models/ai_summary.rb delete mode 100644 app/models/ai_tool.rb delete mode 100644 app/models/chat_message_custom_prompt.rb delete mode 100644 app/models/classification_result.rb delete mode 100644 app/models/completion_prompt.rb delete mode 100644 app/models/embedding_definition.rb delete mode 100644 app/models/inferred_concept.rb delete mode 100644 app/models/inferred_concept_post.rb delete mode 100644 app/models/inferred_concept_topic.rb delete mode 100644 app/models/llm_model.rb delete mode 100644 app/models/llm_quota.rb delete mode 100644 app/models/llm_quota_usage.rb delete mode 100644 app/models/model_accuracy.rb delete mode 100644 app/models/post_custom_prompt.rb delete mode 100644 app/models/rag_document_fragment.rb delete mode 100644 app/models/reviewable_ai_chat_message.rb delete mode 100644 app/models/reviewable_ai_post.rb delete mode 100644 app/models/shared_ai_conversation.rb delete mode 100644 app/serializers/ai_api_audit_log_serializer.rb delete mode 100644 app/serializers/ai_artifact_key_value_serializer.rb delete mode 100644 app/serializers/ai_chat_channel_serializer.rb delete mode 100644 app/serializers/ai_custom_tool_list_serializer.rb delete mode 100644 app/serializers/ai_custom_tool_serializer.rb delete mode 100644 app/serializers/ai_embedding_definition_serializer.rb delete mode 100644 app/serializers/ai_features_persona_serializer.rb delete mode 100644 app/serializers/ai_inferred_concept_post_serializer.rb delete mode 100644 app/serializers/ai_sentiment_post_serializer.rb delete mode 100644 app/serializers/ai_spam_serializer.rb delete mode 100644 app/serializers/ai_tool_serializer.rb delete mode 100644 app/serializers/ai_topic_summary_serializer.rb delete mode 100644 app/serializers/ai_usage_serializer.rb delete mode 100644 app/serializers/basic_llm_model_serializer.rb delete mode 100644 app/serializers/inferred_concept_serializer.rb delete mode 100644 app/serializers/llm_model_serializer.rb delete mode 100644 app/serializers/llm_quota_serializer.rb delete mode 100644 app/serializers/localized_ai_persona_serializer.rb delete mode 100644 app/serializers/reviewable_ai_chat_message_serializer.rb delete mode 100644 app/serializers/reviewable_ai_post_serializer.rb delete mode 100644 app/services/discourse_ai/persona_exporter.rb delete mode 100644 app/services/discourse_ai/persona_importer.rb delete mode 100644 app/services/discourse_ai/topic_summarization.rb delete mode 100644 app/services/problem_check/ai_llm_status.rb delete mode 100644 app/views/discourse_ai/ai_bot/shared_ai_conversations/show.html.erb delete mode 100644 app/views/list/related_topics.html.erb delete mode 100644 assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js delete mode 100644 assets/javascripts/discourse/admin-discourse-ai-route-map.js delete mode 100644 assets/javascripts/discourse/admin/adapters/ai-embedding.js delete mode 100644 assets/javascripts/discourse/admin/adapters/ai-feature.js delete mode 100644 assets/javascripts/discourse/admin/adapters/ai-llm.js delete mode 100644 assets/javascripts/discourse/admin/adapters/ai-persona.js delete mode 100644 assets/javascripts/discourse/admin/adapters/ai-tool.js delete mode 100644 assets/javascripts/discourse/admin/models/ai-embedding.js delete mode 100644 assets/javascripts/discourse/admin/models/ai-feature.js delete mode 100644 assets/javascripts/discourse/admin/models/ai-llm.js delete mode 100644 assets/javascripts/discourse/admin/models/ai-persona.js delete mode 100644 assets/javascripts/discourse/admin/models/ai-tool.js delete mode 100644 assets/javascripts/discourse/components/admin-report-emotion.hbs delete mode 100644 assets/javascripts/discourse/components/admin-report-emotion.js delete mode 100644 assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs delete mode 100644 assets/javascripts/discourse/components/ai-artifact.gjs delete mode 100644 assets/javascripts/discourse/components/ai-blinking-animation.gjs delete mode 100644 assets/javascripts/discourse/components/ai-bot-conversations.gjs delete mode 100644 assets/javascripts/discourse/components/ai-bot-header-icon.gjs delete mode 100644 assets/javascripts/discourse/components/ai-bot-sidebar-empty-state.gjs delete mode 100644 assets/javascripts/discourse/components/ai-bot-sidebar-new-conversation.gjs delete mode 100644 assets/javascripts/discourse/components/ai-composer-helper-menu.gjs delete mode 100644 assets/javascripts/discourse/components/ai-embedding-editor.gjs delete mode 100644 assets/javascripts/discourse/components/ai-embeddings-list-editor.gjs delete mode 100644 assets/javascripts/discourse/components/ai-features-list.gjs delete mode 100644 assets/javascripts/discourse/components/ai-features.gjs delete mode 100644 assets/javascripts/discourse/components/ai-full-page-search.gjs delete mode 100644 assets/javascripts/discourse/components/ai-gist-toggle.gjs delete mode 100644 assets/javascripts/discourse/components/ai-helper-custom-prompt.gjs delete mode 100644 assets/javascripts/discourse/components/ai-helper-loading.gjs delete mode 100644 assets/javascripts/discourse/components/ai-helper-options-list.gjs delete mode 100644 assets/javascripts/discourse/components/ai-indicator-wave.gjs delete mode 100644 assets/javascripts/discourse/components/ai-llm-editor-form.gjs delete mode 100644 assets/javascripts/discourse/components/ai-llm-editor.gjs delete mode 100644 assets/javascripts/discourse/components/ai-llm-selector.gjs delete mode 100644 assets/javascripts/discourse/components/ai-llms-list-editor.gjs delete mode 100644 assets/javascripts/discourse/components/ai-persona-editor.gjs delete mode 100644 assets/javascripts/discourse/components/ai-persona-example.gjs delete mode 100644 assets/javascripts/discourse/components/ai-persona-list-editor.gjs delete mode 100644 assets/javascripts/discourse/components/ai-persona-llm-selector.gjs delete mode 100644 assets/javascripts/discourse/components/ai-persona-tool-options.gjs delete mode 100644 assets/javascripts/discourse/components/ai-post-helper-menu.gjs delete mode 100644 assets/javascripts/discourse/components/ai-quota-duration-selector.gjs delete mode 100644 assets/javascripts/discourse/components/ai-search-discoveries-tooltip.gjs delete mode 100644 assets/javascripts/discourse/components/ai-search-discoveries.gjs delete mode 100644 assets/javascripts/discourse/components/ai-sentiment-horizontal-bar.gjs delete mode 100644 assets/javascripts/discourse/components/ai-spam.gjs delete mode 100644 assets/javascripts/discourse/components/ai-split-topic-suggester.gjs delete mode 100644 assets/javascripts/discourse/components/ai-summary-skeleton.gjs delete mode 100644 assets/javascripts/discourse/components/ai-tool-editor-form.gjs delete mode 100644 assets/javascripts/discourse/components/ai-tool-editor.gjs delete mode 100644 assets/javascripts/discourse/components/ai-tool-list-editor.gjs delete mode 100644 assets/javascripts/discourse/components/ai-tool-selector.gjs delete mode 100644 assets/javascripts/discourse/components/ai-topic-gist.gjs delete mode 100644 assets/javascripts/discourse/components/ai-usage.gjs delete mode 100644 assets/javascripts/discourse/components/doughnut-chart.gjs delete mode 100644 assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs delete mode 100644 assets/javascripts/discourse/components/modal/ai-persona-response-format-editor.gjs delete mode 100644 assets/javascripts/discourse/components/modal/ai-summary-modal.gjs delete mode 100644 assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs delete mode 100644 assets/javascripts/discourse/components/modal/chat-modal-channel-summary.gjs delete mode 100644 assets/javascripts/discourse/components/modal/debug-ai-modal.gjs delete mode 100644 assets/javascripts/discourse/components/modal/diff-modal.gjs delete mode 100644 assets/javascripts/discourse/components/modal/share-full-topic-modal.gjs delete mode 100644 assets/javascripts/discourse/components/modal/share-modal.gjs delete mode 100644 assets/javascripts/discourse/components/modal/spam-test-modal.gjs delete mode 100644 assets/javascripts/discourse/components/modal/thumbnail-suggestions.gjs delete mode 100644 assets/javascripts/discourse/components/model-accuracies.hbs delete mode 100644 assets/javascripts/discourse/components/post-menu/ai-cancel-streaming-button.gjs delete mode 100644 assets/javascripts/discourse/components/post-menu/ai-debug-button.gjs delete mode 100644 assets/javascripts/discourse/components/post-menu/ai-share-button.gjs delete mode 100644 assets/javascripts/discourse/components/post/ai-persona-flair.gjs delete mode 100644 assets/javascripts/discourse/components/rag-options-fk.gjs delete mode 100644 assets/javascripts/discourse/components/rag-upload-progress.gjs delete mode 100644 assets/javascripts/discourse/components/rag-uploader.gjs delete mode 100644 assets/javascripts/discourse/components/reviewable-ai-chat-message.hbs delete mode 100644 assets/javascripts/discourse/components/reviewable-ai-chat-message.js delete mode 100644 assets/javascripts/discourse/components/reviewable-ai-post.hbs delete mode 100644 assets/javascripts/discourse/components/suggestion-menus/ai-category-suggester.gjs delete mode 100644 assets/javascripts/discourse/components/suggestion-menus/ai-tag-suggester.gjs delete mode 100644 assets/javascripts/discourse/components/suggestion-menus/ai-title-suggester.gjs delete mode 100644 assets/javascripts/discourse/components/thumbnail-suggestion-item.gjs delete mode 100644 assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs delete mode 100644 assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/after-d-editor/composer-open.hbs delete mode 100644 assets/javascripts/discourse/connectors/after-d-editor/composer-open.js delete mode 100644 assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs delete mode 100644 assets/javascripts/discourse/connectors/before-create-topic-button/topic-list-gist-toggle.gjs delete mode 100644 assets/javascripts/discourse/connectors/composer-after-save-or-cancel/ai-image-caption-loader.gjs delete mode 100644 assets/javascripts/discourse/connectors/composer-fields/persona-llm-selector.gjs delete mode 100644 assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/edit-topic-tags__after/ai-tag-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/edit-topic-title__after/ai-title-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/editor-preview/ai-image-caption-container.gjs delete mode 100644 assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs delete mode 100644 assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-discobot-discoveries.gjs delete mode 100644 assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-search.gjs delete mode 100644 assets/javascripts/discourse/connectors/post-text-buttons/ai-post-helper-trigger.gjs delete mode 100644 assets/javascripts/discourse/connectors/search-menu-before-advanced-search/ai-quick-search-loader.gjs delete mode 100644 assets/javascripts/discourse/connectors/search-menu-initial-options/ai-quick-semantic-search.gjs delete mode 100644 assets/javascripts/discourse/connectors/search-menu-results-top/ai-discobot-discoveries.gjs delete mode 100644 assets/javascripts/discourse/connectors/search-menu-results-top/ai-quick-search-info.gjs delete mode 100644 assets/javascripts/discourse/connectors/split-new-topic-category-after/ai-category-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/split-new-topic-tag-after/ai-tag-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/split-new-topic-title-after/ai-title-suggestion.gjs delete mode 100644 assets/javascripts/discourse/connectors/topic-list-before-category/ai-topic-gist-placement.gjs delete mode 100644 assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-trigger.gjs delete mode 100644 assets/javascripts/discourse/connectors/topic-map-participants-after/ai-conversation-invite.gjs delete mode 100644 assets/javascripts/discourse/connectors/user-preferences-nav/ai-preferences.gjs delete mode 100644 assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js delete mode 100644 assets/javascripts/discourse/controllers/preferences-ai.js delete mode 100644 assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js delete mode 100644 assets/javascripts/discourse/discourse-ai-shared-conversation-show-route-map.js delete mode 100644 assets/javascripts/discourse/lib/ai-bot-helper.js delete mode 100644 assets/javascripts/discourse/lib/ai-helper-suggestions.js delete mode 100644 assets/javascripts/discourse/lib/ai-streamer/progress-handlers.js delete mode 100644 assets/javascripts/discourse/lib/ai-streamer/updaters/post-updater.js delete mode 100644 assets/javascripts/discourse/lib/ai-streamer/updaters/stream-updater.js delete mode 100644 assets/javascripts/discourse/lib/ai-topic-summary.js delete mode 100644 assets/javascripts/discourse/lib/copy-conversation.js delete mode 100644 assets/javascripts/discourse/lib/diff-streamer.gjs delete mode 100644 assets/javascripts/discourse/lib/show-ai-helper.js delete mode 100644 assets/javascripts/discourse/lib/smooth-streamer.gjs delete mode 100644 assets/javascripts/discourse/lib/utilities.js delete mode 100644 assets/javascripts/discourse/preferences-ai-route-map.js delete mode 100644 assets/javascripts/discourse/routes/discourse-ai-shared-conversation-show.js delete mode 100644 assets/javascripts/discourse/routes/preferences-ai.js delete mode 100644 assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js delete mode 100644 assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js delete mode 100644 assets/javascripts/discourse/services/discobot-discoveries.js delete mode 100644 assets/javascripts/discourse/services/gists.js delete mode 100644 assets/javascripts/discourse/services/image-caption-popup.js delete mode 100644 assets/javascripts/discourse/services/quick-search.js delete mode 100644 assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs delete mode 100644 assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs delete mode 100644 assets/javascripts/discourse/templates/preferences/ai.gjs delete mode 100644 assets/javascripts/initializers/admin-plugin-configuration-nav.js delete mode 100644 assets/javascripts/initializers/admin-reports.js delete mode 100644 assets/javascripts/initializers/ai-artifacts.gjs delete mode 100644 assets/javascripts/initializers/ai-bot-replies.js delete mode 100644 assets/javascripts/initializers/ai-chat-summarization.js delete mode 100644 assets/javascripts/initializers/ai-conversations-sidebar.js delete mode 100644 assets/javascripts/initializers/ai-gist-topic-list-class.js delete mode 100644 assets/javascripts/initializers/ai-helper.js delete mode 100644 assets/javascripts/initializers/ai-image-caption.js delete mode 100644 assets/javascripts/initializers/ai-search-discoveries.js delete mode 100644 assets/javascripts/initializers/ai-semantic-search.js delete mode 100644 assets/javascripts/initializers/ai-sentiment-admin-nav.js delete mode 100644 assets/javascripts/initializers/related-topics.gjs delete mode 100644 assets/javascripts/initializers/translation.js delete mode 100644 assets/javascripts/lib/discourse-markdown/ai-tags.js delete mode 100644 assets/stylesheets/common/ai-blinking-animation.scss delete mode 100644 assets/stylesheets/common/ai-features.scss delete mode 100644 assets/stylesheets/common/ai-user-settings.scss delete mode 100644 assets/stylesheets/common/streaming.scss delete mode 100644 assets/stylesheets/modules/ai-bot-conversations/common.scss delete mode 100644 assets/stylesheets/modules/ai-bot/common/ai-artifact.scss delete mode 100644 assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss delete mode 100644 assets/stylesheets/modules/ai-bot/common/ai-persona.scss delete mode 100644 assets/stylesheets/modules/ai-bot/common/ai-tools.scss delete mode 100644 assets/stylesheets/modules/ai-bot/common/bot-replies.scss delete mode 100644 assets/stylesheets/modules/ai-bot/mobile/ai-persona.scss delete mode 100644 assets/stylesheets/modules/ai-helper/common/ai-helper.scss delete mode 100644 assets/stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss delete mode 100644 assets/stylesheets/modules/ai-helper/mobile/ai-helper.scss delete mode 100644 assets/stylesheets/modules/embeddings/common/ai-embedding-editor.scss delete mode 100644 assets/stylesheets/modules/embeddings/common/semantic-related-topics.scss delete mode 100644 assets/stylesheets/modules/embeddings/common/semantic-search.scss delete mode 100644 assets/stylesheets/modules/llms/common/ai-llm-quotas.scss delete mode 100644 assets/stylesheets/modules/llms/common/ai-llms-editor.scss delete mode 100644 assets/stylesheets/modules/llms/common/spam.scss delete mode 100644 assets/stylesheets/modules/llms/common/usage.scss delete mode 100644 assets/stylesheets/modules/sentiment/common/dashboard.scss delete mode 100644 assets/stylesheets/modules/summarization/common/ai-gists.scss delete mode 100644 assets/stylesheets/modules/summarization/common/ai-summary.scss delete mode 100644 assets/stylesheets/modules/summarization/desktop/ai-summary.scss delete mode 100644 config/eval-llms.yml delete mode 100644 config/locales/client.ar.yml delete mode 100644 config/locales/client.be.yml delete mode 100644 config/locales/client.bg.yml delete mode 100644 config/locales/client.bs_BA.yml delete mode 100644 config/locales/client.ca.yml delete mode 100644 config/locales/client.cs.yml delete mode 100644 config/locales/client.da.yml delete mode 100644 config/locales/client.de.yml delete mode 100644 config/locales/client.el.yml delete mode 100644 config/locales/client.en.yml delete mode 100644 config/locales/client.en_GB.yml delete mode 100644 config/locales/client.es.yml delete mode 100644 config/locales/client.et.yml delete mode 100644 config/locales/client.fa_IR.yml delete mode 100644 config/locales/client.fi.yml delete mode 100644 config/locales/client.fr.yml delete mode 100644 config/locales/client.gl.yml delete mode 100644 config/locales/client.he.yml delete mode 100644 config/locales/client.hr.yml delete mode 100644 config/locales/client.hu.yml delete mode 100644 config/locales/client.hy.yml delete mode 100644 config/locales/client.id.yml delete mode 100644 config/locales/client.it.yml delete mode 100644 config/locales/client.ja.yml delete mode 100644 config/locales/client.ko.yml delete mode 100644 config/locales/client.lt.yml delete mode 100644 config/locales/client.lv.yml delete mode 100644 config/locales/client.nb_NO.yml delete mode 100644 config/locales/client.nl.yml delete mode 100644 config/locales/client.pl_PL.yml delete mode 100644 config/locales/client.pt.yml delete mode 100644 config/locales/client.pt_BR.yml delete mode 100644 config/locales/client.ro.yml delete mode 100644 config/locales/client.ru.yml delete mode 100644 config/locales/client.sk.yml delete mode 100644 config/locales/client.sl.yml delete mode 100644 config/locales/client.sq.yml delete mode 100644 config/locales/client.sr.yml delete mode 100644 config/locales/client.sv.yml delete mode 100644 config/locales/client.sw.yml delete mode 100644 config/locales/client.te.yml delete mode 100644 config/locales/client.th.yml delete mode 100644 config/locales/client.tr_TR.yml delete mode 100644 config/locales/client.ug.yml delete mode 100644 config/locales/client.uk.yml delete mode 100644 config/locales/client.ur.yml delete mode 100644 config/locales/client.vi.yml delete mode 100644 config/locales/client.zh_CN.yml delete mode 100644 config/locales/client.zh_TW.yml delete mode 100644 config/locales/server.ar.yml delete mode 100644 config/locales/server.be.yml delete mode 100644 config/locales/server.bg.yml delete mode 100644 config/locales/server.bs_BA.yml delete mode 100644 config/locales/server.ca.yml delete mode 100644 config/locales/server.cs.yml delete mode 100644 config/locales/server.da.yml delete mode 100644 config/locales/server.de.yml delete mode 100644 config/locales/server.el.yml delete mode 100644 config/locales/server.en.yml delete mode 100644 config/locales/server.en_GB.yml delete mode 100644 config/locales/server.es.yml delete mode 100644 config/locales/server.et.yml delete mode 100644 config/locales/server.fa_IR.yml delete mode 100644 config/locales/server.fi.yml delete mode 100644 config/locales/server.fr.yml delete mode 100644 config/locales/server.gl.yml delete mode 100644 config/locales/server.he.yml delete mode 100644 config/locales/server.hr.yml delete mode 100644 config/locales/server.hu.yml delete mode 100644 config/locales/server.hy.yml delete mode 100644 config/locales/server.id.yml delete mode 100644 config/locales/server.it.yml delete mode 100644 config/locales/server.ja.yml delete mode 100644 config/locales/server.ko.yml delete mode 100644 config/locales/server.lt.yml delete mode 100644 config/locales/server.lv.yml delete mode 100644 config/locales/server.nb_NO.yml delete mode 100644 config/locales/server.nl.yml delete mode 100644 config/locales/server.pl_PL.yml delete mode 100644 config/locales/server.pt.yml delete mode 100644 config/locales/server.pt_BR.yml delete mode 100644 config/locales/server.ro.yml delete mode 100644 config/locales/server.ru.yml delete mode 100644 config/locales/server.sk.yml delete mode 100644 config/locales/server.sl.yml delete mode 100644 config/locales/server.sq.yml delete mode 100644 config/locales/server.sr.yml delete mode 100644 config/locales/server.sv.yml delete mode 100644 config/locales/server.sw.yml delete mode 100644 config/locales/server.te.yml delete mode 100644 config/locales/server.th.yml delete mode 100644 config/locales/server.tr_TR.yml delete mode 100644 config/locales/server.ug.yml delete mode 100644 config/locales/server.uk.yml delete mode 100644 config/locales/server.ur.yml delete mode 100644 config/locales/server.vi.yml delete mode 100644 config/locales/server.zh_CN.yml delete mode 100644 config/locales/server.zh_TW.yml delete mode 100644 config/routes.rb delete mode 100644 config/settings.yml delete mode 100644 db/fixtures/ai_bot/602_bot_users.rb delete mode 100644 db/fixtures/personas/603_ai_personas.rb delete mode 100644 db/migrate/20230224165056_create_classification_results_table.rb delete mode 100644 db/migrate/20230307125342_created_model_accuracy_table.rb delete mode 100644 db/migrate/20230314184514_migrate_discourse_ai_reviewables.rb delete mode 100644 db/migrate/20230316160714_create_completion_prompt_table.rb delete mode 100644 db/migrate/20230320122645_delete_duplicated_seeded_prompts.rb delete mode 100644 db/migrate/20230320185619_multi_message_completion_prompts.rb delete mode 100644 db/migrate/20230320191928_drop_completion_prompt_value.rb delete mode 100644 db/migrate/20230322142028_make_dropped_value_column_nullable.rb delete mode 100644 db/migrate/20230406135943_add_provider_to_completion_prompts.rb delete mode 100644 db/migrate/20230424055354_create_ai_api_audit_logs.rb delete mode 100644 db/migrate/20230519003106_post_custom_prompts.rb delete mode 100644 db/migrate/20230710171141_enable_pg_vector_extension.rb delete mode 100644 db/migrate/20230710171142_create_ai_topic_embeddings_table.rb delete mode 100644 db/migrate/20230710171143_migrate_embeddings_from_dedicated_database.rb delete mode 100644 db/migrate/20230727170222_create_multilingual_topic_embeddings_table.rb delete mode 100644 db/migrate/20230831033812_rename_ai_helper_add_ai_pm_to_header_setting.rb delete mode 100644 db/migrate/20231003155701_create_bge_topic_embeddings_table.rb delete mode 100644 db/migrate/20231031050538_add_topic_id_post_id_to_ai_audit_log.rb delete mode 100644 db/migrate/20231109011155_create_ai_personas.rb delete mode 100644 db/migrate/20231117050928_add_system_and_priority_to_ai_personas.rb delete mode 100644 db/migrate/20231120033747_remove_site_settings.rb delete mode 100644 db/migrate/20231123224203_switch_to_generic_completion_prompts.rb delete mode 100644 db/migrate/20231128151234_recreate_generate_titles_prompt.rb delete mode 100644 db/migrate/20231202013850_convert_ai_personas_commands_to_json.rb delete mode 100644 db/migrate/20231227223301_create_gemini_topic_embeddings_table.rb delete mode 100644 db/migrate/20231228213036_create_ai_post_embeddings_tables.rb delete mode 100644 db/migrate/20240104013944_add_params_to_completion_prompt.rb delete mode 100644 db/migrate/20240119152348_explicit_provider_backwards_compat.rb delete mode 100644 db/migrate/20240126013358_create_openai_text_embedding_tables.rb delete mode 100644 db/migrate/20240202010752_add_temperature_top_p_to_ai_personas.rb delete mode 100644 db/migrate/20240207144910_fix_llm_backed_setting_defaults.rb delete mode 100644 db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb delete mode 100644 db/migrate/20240213051213_add_limits_to_ai_persona.rb delete mode 100644 db/migrate/20240309034751_add_shared_ai_conversations.rb delete mode 100644 db/migrate/20240309034752_create_rag_document_fragment_table.rb delete mode 100644 db/migrate/20240313165121_embedding_tables_for_rag_uploads.rb delete mode 100644 db/migrate/20240322035907_add_images_to_ai_personas.rb delete mode 100644 db/migrate/20240404000838_add_metadata_to_rag_document_frament.rb delete mode 100644 db/migrate/20240409035951_add_rag_params_to_ai_persona.rb delete mode 100644 db/migrate/20240410170000_add_embeddings_tablesfor_bge_m3.rb delete mode 100644 db/migrate/20240424220101_add_auto_image_caption_to_user_options.rb delete mode 100644 db/migrate/20240429065155_add_consolidated_question_llm_to_ai_persona.rb delete mode 100644 db/migrate/20240503034946_add_allow_chat_to_ai_persona.rb delete mode 100644 db/migrate/20240503042558_add_chat_message_custom_prompt.rb delete mode 100644 db/migrate/20240504222307_create_llm_model_table.rb delete mode 100644 db/migrate/20240514001334_add_feature_name_to_ai_api_audit_log.rb delete mode 100644 db/migrate/20240514171609_add_endpoint_data_to_llm_model.rb delete mode 100644 db/migrate/20240527054218_add_language_model_to_ai_audit_logs.rb delete mode 100644 db/migrate/20240528132059_add_companion_user_to_llm_model.rb delete mode 100644 db/migrate/20240528144216_seed_open_ai_models.rb delete mode 100644 db/migrate/20240531205234_seed_claude_models.rb delete mode 100644 db/migrate/20240603133432_seed_other_propietary_models.rb delete mode 100644 db/migrate/20240603143158_seed_oss_models.rb delete mode 100644 db/migrate/20240606151348_create_ai_summaries_table.rb delete mode 100644 db/migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb delete mode 100644 db/migrate/20240609061418_tool_details_and_command_removal.rb delete mode 100644 db/migrate/20240609232736_drop_commands_from_ai_personas.rb delete mode 100644 db/migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb delete mode 100644 db/migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb delete mode 100644 db/migrate/20240611170904_upgrade_pgvector_070.rb delete mode 100644 db/migrate/20240611170905_move_embeddings_to_single_table_per_type.rb delete mode 100644 db/migrate/20240611170906_drop_old_embeddings_tables.rb delete mode 100644 db/migrate/20240618080148_create_ai_tools.rb delete mode 100644 db/migrate/20240619193057_choose_llm_model_setting_migration.rb delete mode 100644 db/migrate/20240619211337_update_automation_script_models.rb delete mode 100644 db/migrate/20240624135356_llm_model_custom_params.rb delete mode 100644 db/migrate/20240624202602_add_provider_specific_params_to_llm_models.rb delete mode 100644 db/migrate/20240703135444_llm_models_for_summarization.rb delete mode 100644 db/migrate/20240704020102_reset_identity_on_ai_summary.rb delete mode 100644 db/migrate/20240708193243_fix_vllm_model_name.rb delete mode 100644 db/migrate/20240719143453_llm_model_vision_enabled.rb delete mode 100644 db/migrate/20240726164937_fix_ai_summaries_sequence.rb delete mode 100644 db/migrate/20240807150605_add_default_to_provider_params.rb delete mode 100644 db/migrate/20240909180908_add_ai_summary_type_column.rb delete mode 100644 db/migrate/20240912052713_add_target_to_rag_document_fragment.rb delete mode 100644 db/migrate/20240913054440_add_rag_columns_to_ai_tools.rb delete mode 100644 db/migrate/20241008054440_create_binary_indexes_for_embeddings.rb delete mode 100644 db/migrate/20241009230724_add_forced_tool_count_to_ai_personas.rb delete mode 100644 db/migrate/20241014010245_ai_persona_chat_topic_refactor.rb delete mode 100644 db/migrate/20241020010245_add_tool_name_to_ai_tools.rb delete mode 100644 db/migrate/20241023033955_add_feature_context_to_ai_api_log.rb delete mode 100644 db/migrate/20241023041242_add_unique_constraint_to_ai_tools.rb delete mode 100644 db/migrate/20241025135522_alter_ai_ids_to_bigint.rb delete mode 100644 db/migrate/20241028034232_add_unique_ai_stream_conversation_user_id_index.rb delete mode 100644 db/migrate/20241031145203_track_ai_summary_origin.rb delete mode 100644 db/migrate/20241031180044_set_origin_for_existing_ai_summaries.rb delete mode 100644 db/migrate/20241104053017_add_ai_artifacts.rb delete mode 100644 db/migrate/20241125132452_unique_ai_summaries.rb delete mode 100644 db/migrate/20241126033812_rename_ai_gist_batch_setting.rb delete mode 100644 db/migrate/20241128010221_add_cached_tokens_to_ai_api_audit_log.rb delete mode 100644 db/migrate/20241129190708_fix_classification_data.rb delete mode 100644 db/migrate/20241130003808_add_artifact_versions.rb delete mode 100644 db/migrate/20241206030229_add_ai_moderation_settings.rb delete mode 100644 db/migrate/20241206051225_add_ai_spam_logs.rb delete mode 100644 db/migrate/20241217164540_create_embedding_definitions.rb delete mode 100644 db/migrate/20241230153300_new_embeddings_tables.rb delete mode 100644 db/migrate/20250102035341_add_llm_quota_tables.rb delete mode 100644 db/migrate/20250110114305_embedding_config_data_migration.rb delete mode 100644 db/migrate/20250114160417_backfill_topic_embeddings.rb delete mode 100644 db/migrate/20250114160446_backfill_post_embeddings.rb delete mode 100644 db/migrate/20250114160500_backfill_rag_embeddings.rb delete mode 100644 db/migrate/20250115173456_add_highest_target_number_to_ai_summary.rb delete mode 100644 db/migrate/20250121162520_configurable_embeddings_prefixes.rb delete mode 100644 db/migrate/20250122003035_add_duration_to_ai_api_log.rb delete mode 100644 db/migrate/20250122131007_matryoshka_dimensions_support.rb delete mode 100644 db/migrate/20250125162658_fix_broken_open_ai_embeddings_config.rb delete mode 100644 db/migrate/20250127145305_clean_unused_embedding_search_indexes.rb delete mode 100644 db/migrate/20250210024600_add_rag_llm_model.rb delete mode 100644 db/migrate/20250210032345_migrate_persona_to_llm_model_id.rb delete mode 100644 db/migrate/20250211021037_add_error_to_ai_spam_log.rb delete mode 100644 db/migrate/20250310172527_add_ai_search_discoveries_to_user_options.rb delete mode 100644 db/migrate/20250407125756_set_correct_default_for_short_summarizer_persona.rb delete mode 100644 db/migrate/20250411121705_add_response_format_json_to_personass.rb delete mode 100644 db/migrate/20250416215039_add_cost_metrics_to_llm_model.rb delete mode 100644 db/migrate/20250417194503_add_max_output_tokens_to_llm_model.rb delete mode 100644 db/migrate/20250424035234_remove_old_settings.rb delete mode 100644 db/migrate/20250424163718_set_ai_bot_pm_custom_fields.rb delete mode 100644 db/migrate/20250429060311_move_dall_e_url.rb delete mode 100644 db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb delete mode 100644 db/migrate/20250508154953_add_examples_to_personas.rb delete mode 100644 db/migrate/20250508182047_create_inferred_concepts_table.rb delete mode 100644 db/migrate/20250508183456_create_inferred_concept_topics.rb delete mode 100644 db/migrate/20250509000001_create_inferred_concept_posts.rb delete mode 100644 db/migrate/20250607071239_create_ai_artifacts_key_values.rb delete mode 100644 db/migrate/20250619105705_add_persona_to_ai_moderation_settings.rb delete mode 100644 db/migrate/20250620073222_specify_rate_frequency_in_backfill_setting.rb delete mode 100644 db/migrate/20250702073222_rename_mixtral_tokenizer_to_mistral_tokenizer.rb delete mode 100644 db/migrate/20250715165701_update_open_ai_embeddings_tokenizer.rb delete mode 100644 db/migrate/20250717075002_set_translation_backfill_max_age.rb delete mode 100644 db/migrate/20250721192553_enable_ai_if_already_installed.rb delete mode 100644 db/migrate/20250722082515_add_index_to_ai_topics_embeddings.rb delete mode 100644 db/post_migrate/20240724174343_migrate_vision_llms.rb delete mode 100644 db/post_migrate/20240729202857_migrate_persona_llm_override.rb delete mode 100644 db/post_migrate/20240809162837_rename_ai_helper_enabled_setting.rb delete mode 100644 db/post_migrate/20240809163303_rename_ai_helper_allowed_groups_setting.rb delete mode 100644 db/post_migrate/20240912055831_drop_persona_id_from_rag_document_fragments.rb delete mode 100644 db/post_migrate/20241008055831_drop_old_embeddings_indexes.rb delete mode 100644 db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb delete mode 100644 db/post_migrate/20241031041242_migrate_sentiment_classification_result_format.rb delete mode 100644 db/post_migrate/20241206115958_rebake_shared_ai_conversation_oneboxes.rb delete mode 100644 db/post_migrate/20250113171444_drop_old_embedding_tables.rb delete mode 100644 db/post_migrate/20250114184356_drop_old_embedding_tables2.rb delete mode 100644 db/post_migrate/20250115181147_drop_ai_summaries_content_range.rb delete mode 100644 db/post_migrate/20250210032351_post_migrate_persona_to_llm_model_id.rb delete mode 100644 db/post_migrate/20250404045050_migrate_users_to_email_group.rb delete mode 100644 discourse_automation/llm_persona_triage.rb delete mode 100644 discourse_automation/llm_report.rb delete mode 100644 discourse_automation/llm_tool_triage.rb delete mode 100644 discourse_automation/llm_triage.rb delete mode 100644 eslint.config.mjs delete mode 100644 evals/lib/boot.rb delete mode 100644 evals/lib/cli.rb delete mode 100644 evals/lib/eval.rb delete mode 100644 evals/lib/llm.rb delete mode 100644 evals/lib/prompts/prompt_evaluator.rb delete mode 100644 evals/lib/prompts/single_test_runner.rb delete mode 100644 evals/lib/runner.rb delete mode 100755 evals/run delete mode 100644 lib/ai_bot/chat_streamer.rb delete mode 100644 lib/ai_bot/entry_point.rb delete mode 100644 lib/ai_bot/playground.rb delete mode 100644 lib/ai_bot/post_streamer.rb delete mode 100644 lib/ai_bot/response_http_streamer.rb delete mode 100644 lib/ai_bot/site_settings_extension.rb delete mode 100644 lib/ai_helper/assistant.rb delete mode 100644 lib/ai_helper/chat_thread_titler.rb delete mode 100644 lib/ai_helper/date_formatter.rb delete mode 100644 lib/ai_helper/entry_point.rb delete mode 100644 lib/ai_helper/painter.rb delete mode 100644 lib/ai_helper/semantic_categorizer.rb delete mode 100644 lib/ai_moderation/entry_point.rb delete mode 100644 lib/ai_moderation/spam_metric.rb delete mode 100644 lib/ai_moderation/spam_report.rb delete mode 100644 lib/ai_moderation/spam_scanner.rb delete mode 100644 lib/automation.rb delete mode 100644 lib/automation/llm_persona_triage.rb delete mode 100644 lib/automation/llm_tool_triage.rb delete mode 100644 lib/automation/llm_triage.rb delete mode 100644 lib/automation/report_context_generator.rb delete mode 100644 lib/automation/report_runner.rb delete mode 100644 lib/completions/anthropic_message_processor.rb delete mode 100644 lib/completions/cancel_manager.rb delete mode 100644 lib/completions/dialects/chat_gpt.rb delete mode 100644 lib/completions/dialects/claude.rb delete mode 100644 lib/completions/dialects/claude_tools.rb delete mode 100644 lib/completions/dialects/cohere_tools.rb delete mode 100644 lib/completions/dialects/command.rb delete mode 100644 lib/completions/dialects/dialect.rb delete mode 100644 lib/completions/dialects/fake.rb delete mode 100644 lib/completions/dialects/gemini.rb delete mode 100644 lib/completions/dialects/mistral.rb delete mode 100644 lib/completions/dialects/nova.rb delete mode 100644 lib/completions/dialects/nova_tools.rb delete mode 100644 lib/completions/dialects/ollama.rb delete mode 100644 lib/completions/dialects/ollama_tools.rb delete mode 100644 lib/completions/dialects/open_ai_compatible.rb delete mode 100644 lib/completions/dialects/open_ai_tools.rb delete mode 100644 lib/completions/dialects/xml_tools.rb delete mode 100644 lib/completions/endpoints/anthropic.rb delete mode 100644 lib/completions/endpoints/aws_bedrock.rb delete mode 100644 lib/completions/endpoints/base.rb delete mode 100644 lib/completions/endpoints/canned_response.rb delete mode 100644 lib/completions/endpoints/cohere.rb delete mode 100644 lib/completions/endpoints/fake.rb delete mode 100644 lib/completions/endpoints/gemini.rb delete mode 100644 lib/completions/endpoints/hugging_face.rb delete mode 100644 lib/completions/endpoints/mistral.rb delete mode 100644 lib/completions/endpoints/ollama.rb delete mode 100644 lib/completions/endpoints/open_ai.rb delete mode 100644 lib/completions/endpoints/open_router.rb delete mode 100644 lib/completions/endpoints/samba_nova.rb delete mode 100644 lib/completions/endpoints/vllm.rb delete mode 100644 lib/completions/json_stream_decoder.rb delete mode 100644 lib/completions/json_streaming_parser.rb delete mode 100644 lib/completions/json_streaming_tracker.rb delete mode 100644 lib/completions/llm.rb delete mode 100644 lib/completions/nova_message_processor.rb delete mode 100644 lib/completions/open_ai_message_processor.rb delete mode 100644 lib/completions/open_ai_responses_message_processor.rb delete mode 100644 lib/completions/prompt.rb delete mode 100644 lib/completions/prompt_messages_builder.rb delete mode 100644 lib/completions/report.rb delete mode 100644 lib/completions/structured_output.rb delete mode 100644 lib/completions/thinking.rb delete mode 100644 lib/completions/tool_call.rb delete mode 100644 lib/completions/tool_definition.rb delete mode 100644 lib/completions/upload_encoder.rb delete mode 100644 lib/completions/xml_tag_stripper.rb delete mode 100644 lib/completions/xml_tool_processor.rb delete mode 100644 lib/configuration/embedding_defs_enumerator.rb delete mode 100644 lib/configuration/embedding_defs_validator.rb delete mode 100644 lib/configuration/embeddings_model_validator.rb delete mode 100644 lib/configuration/embeddings_module_validator.rb delete mode 100644 lib/configuration/feature.rb delete mode 100644 lib/configuration/llm_dependency_validator.rb delete mode 100644 lib/configuration/llm_enumerator.rb delete mode 100644 lib/configuration/llm_validator.rb delete mode 100644 lib/configuration/llm_vision_enumerator.rb delete mode 100644 lib/configuration/module.rb delete mode 100644 lib/configuration/persona_enumerator.rb delete mode 100644 lib/configuration/spam_detection_validator.rb delete mode 100644 lib/database/connection.rb delete mode 100644 lib/discord/bot/base.rb delete mode 100644 lib/discord/bot/persona_replier.rb delete mode 100644 lib/discord/bot/search.rb delete mode 100644 lib/embeddings.rb delete mode 100644 lib/embeddings/entry_point.rb delete mode 100644 lib/embeddings/schema.rb delete mode 100644 lib/embeddings/semantic_related.rb delete mode 100644 lib/embeddings/semantic_search.rb delete mode 100644 lib/embeddings/semantic_topic_query.rb delete mode 100644 lib/embeddings/strategies/truncation.rb delete mode 100644 lib/embeddings/vector.rb delete mode 100644 lib/engine.rb delete mode 100644 lib/guardian_extensions.rb delete mode 100644 lib/inference/cloudflare_workers_ai.rb delete mode 100644 lib/inference/discourse_reranker.rb delete mode 100644 lib/inference/gemini_embeddings.rb delete mode 100644 lib/inference/hugging_face_text_embeddings.rb delete mode 100644 lib/inference/open_ai_embeddings.rb delete mode 100644 lib/inference/open_ai_image_generator.rb delete mode 100644 lib/inference/stability_generator.rb delete mode 100644 lib/inferred_concepts/applier.rb delete mode 100644 lib/inferred_concepts/finder.rb delete mode 100644 lib/inferred_concepts/manager.rb delete mode 100644 lib/multisite_hash.rb delete mode 100644 lib/personas/artifact_update_strategies/base.rb delete mode 100644 lib/personas/artifact_update_strategies/diff.rb delete mode 100644 lib/personas/artifact_update_strategies/full.rb delete mode 100644 lib/personas/artist.rb delete mode 100644 lib/personas/bot.rb delete mode 100644 lib/personas/bot_context.rb delete mode 100644 lib/personas/concept_deduplicator.rb delete mode 100644 lib/personas/concept_finder.rb delete mode 100644 lib/personas/concept_matcher.rb delete mode 100644 lib/personas/content_creator.rb delete mode 100644 lib/personas/creative.rb delete mode 100644 lib/personas/custom_prompt.rb delete mode 100644 lib/personas/dall_e_3.rb delete mode 100644 lib/personas/designer.rb delete mode 100644 lib/personas/discourse_helper.rb delete mode 100644 lib/personas/forum_researcher.rb delete mode 100644 lib/personas/general.rb delete mode 100644 lib/personas/github_helper.rb delete mode 100644 lib/personas/image_captioner.rb delete mode 100644 lib/personas/locale_detector.rb delete mode 100644 lib/personas/markdown_table_generator.rb delete mode 100644 lib/personas/persona.rb delete mode 100644 lib/personas/post_illustrator.rb delete mode 100644 lib/personas/post_raw_translator.rb delete mode 100644 lib/personas/proofreader.rb delete mode 100644 lib/personas/question_consolidator.rb delete mode 100644 lib/personas/report_runner.rb delete mode 100644 lib/personas/researcher.rb delete mode 100644 lib/personas/settings_explorer.rb delete mode 100644 lib/personas/short_summarizer.rb delete mode 100644 lib/personas/short_text_translator.rb delete mode 100644 lib/personas/smart_dates.rb delete mode 100644 lib/personas/spam_detector.rb delete mode 100644 lib/personas/sql_helper.rb delete mode 100644 lib/personas/summarizer.rb delete mode 100644 lib/personas/titles_generator.rb delete mode 100644 lib/personas/tool_runner.rb delete mode 100644 lib/personas/tools/create_artifact.rb delete mode 100644 lib/personas/tools/create_image.rb delete mode 100644 lib/personas/tools/custom.rb delete mode 100644 lib/personas/tools/dall_e.rb delete mode 100644 lib/personas/tools/db_schema.rb delete mode 100644 lib/personas/tools/discourse_meta_search.rb delete mode 100644 lib/personas/tools/edit_image.rb delete mode 100644 lib/personas/tools/github_file_content.rb delete mode 100644 lib/personas/tools/github_pull_request_diff.rb delete mode 100644 lib/personas/tools/github_search_code.rb delete mode 100644 lib/personas/tools/github_search_files.rb delete mode 100644 lib/personas/tools/google.rb delete mode 100644 lib/personas/tools/image.rb delete mode 100644 lib/personas/tools/javascript_evaluator.rb delete mode 100644 lib/personas/tools/list_categories.rb delete mode 100644 lib/personas/tools/list_tags.rb delete mode 100644 lib/personas/tools/option.rb delete mode 100644 lib/personas/tools/random_picker.rb delete mode 100644 lib/personas/tools/read.rb delete mode 100644 lib/personas/tools/read_artifact.rb delete mode 100644 lib/personas/tools/researcher.rb delete mode 100644 lib/personas/tools/search.rb delete mode 100644 lib/personas/tools/search_settings.rb delete mode 100644 lib/personas/tools/setting_context.rb delete mode 100644 lib/personas/tools/summarize.rb delete mode 100644 lib/personas/tools/time.rb delete mode 100644 lib/personas/tools/tool.rb delete mode 100644 lib/personas/tools/update_artifact.rb delete mode 100644 lib/personas/tools/web_browser.rb delete mode 100644 lib/personas/topic_title_translator.rb delete mode 100644 lib/personas/translator.rb delete mode 100644 lib/personas/tutor.rb delete mode 100644 lib/personas/web_artifact_creator.rb delete mode 100644 lib/post_extensions.rb delete mode 100644 lib/sentiment/constants.rb delete mode 100644 lib/sentiment/emotion_dashboard_report.rb delete mode 100644 lib/sentiment/emotion_filter_order.rb delete mode 100644 lib/sentiment/emotions.rb delete mode 100644 lib/sentiment/entry_point.rb delete mode 100644 lib/sentiment/post_classification.rb delete mode 100644 lib/sentiment/sentiment_analysis_report.rb delete mode 100644 lib/sentiment/sentiment_dashboard_report.rb delete mode 100644 lib/sentiment/sentiment_site_setting_json_schema.rb delete mode 100644 lib/summarization.rb delete mode 100644 lib/summarization/entry_point.rb delete mode 100644 lib/summarization/fold_content.rb delete mode 100644 lib/summarization/strategies/base.rb delete mode 100644 lib/summarization/strategies/chat_messages.rb delete mode 100644 lib/summarization/strategies/hot_topic_gists.rb delete mode 100644 lib/summarization/strategies/topic_summary.rb delete mode 100644 lib/tasks/create_topics.rake delete mode 100644 lib/tasks/modules/ai_moderation/scan.rake delete mode 100644 lib/tasks/modules/embeddings/database.rake delete mode 100644 lib/tasks/modules/sentiment/backfill.rake delete mode 100644 lib/tasks/modules/sentiment/populate.rake delete mode 100644 lib/tasks/modules/toxicity/calibration.rake delete mode 100644 lib/topic_extensions.rb delete mode 100644 lib/translation.rb delete mode 100644 lib/translation/base_translator.rb delete mode 100644 lib/translation/category_locale_detector.rb delete mode 100644 lib/translation/category_localizer.rb delete mode 100644 lib/translation/content_splitter.rb delete mode 100644 lib/translation/entry_point.rb delete mode 100644 lib/translation/language_detector.rb delete mode 100644 lib/translation/post_detection_text.rb delete mode 100644 lib/translation/post_locale_detector.rb delete mode 100644 lib/translation/post_localizer.rb delete mode 100644 lib/translation/post_raw_translator.rb delete mode 100644 lib/translation/short_text_translator.rb delete mode 100644 lib/translation/topic_locale_detector.rb delete mode 100644 lib/translation/topic_localizer.rb delete mode 100644 lib/translation/topic_title_translator.rb delete mode 100644 lib/translation/verbose_logger.rb delete mode 100644 lib/utils/ai_staff_action_logger.rb delete mode 100644 lib/utils/best_effort_json_parser.rb delete mode 100644 lib/utils/diff_utils/hunk_diff.rb delete mode 100644 lib/utils/diff_utils/safety_checker.rb delete mode 100644 lib/utils/diff_utils/simple_diff.rb delete mode 100644 lib/utils/dns_srv.rb delete mode 100644 lib/utils/image_to_text.rb delete mode 100644 lib/utils/pdf_to_images.rb delete mode 100644 lib/utils/pdf_to_text.rb delete mode 100644 lib/utils/research/filter.rb delete mode 100644 lib/utils/research/llm_formatter.rb delete mode 100644 lib/utils/search.rb delete mode 100644 package.json delete mode 100644 plugin.rb delete mode 100644 pnpm-lock.yaml delete mode 100644 public/ai-share/highlight.min.js delete mode 100644 public/ai-share/share.css delete mode 100644 spec/configuration/feature_spec.rb delete mode 100644 spec/configuration/llm_enumerator_spec.rb delete mode 100644 spec/configuration/llm_validator_spec.rb delete mode 100644 spec/configuration/spam_detection_validator_spec.rb delete mode 100644 spec/db/migrate/20241031041242_migrate_sentiment_classification_result_format_spec.rb delete mode 100644 spec/db/migrate/20250125162658_fix_broken_open_ai_embeddings_config_spec.rb delete mode 100644 spec/db/migrate/20250127145305_clean_unused_embedding_search_indexes_spec.rb delete mode 100644 spec/fabricators/ai_artifact_fabricator.rb delete mode 100644 spec/fabricators/ai_persona_fabricator.rb delete mode 100644 spec/fabricators/ai_summary_fabricator.rb delete mode 100644 spec/fabricators/ai_tool_fabricator.rb delete mode 100644 spec/fabricators/classification_result_fabricator.rb delete mode 100644 spec/fabricators/embedding_definition_fabricator.rb delete mode 100644 spec/fabricators/inferred_concept_fabricator.rb delete mode 100644 spec/fabricators/llm_model_fabricator.rb delete mode 100644 spec/fabricators/llm_quota_fabricator.rb delete mode 100644 spec/fabricators/llm_quota_usage_fabricator.rb delete mode 100644 spec/fabricators/rag_document_fragment_fabricator.rb delete mode 100644 spec/fixtures/bot/openai_artifact_call.txt delete mode 100644 spec/fixtures/embeddings/embedding.txt delete mode 100644 spec/fixtures/images/100x100.jpg delete mode 100644 spec/fixtures/images/1x1.gif delete mode 100644 spec/fixtures/images/1x1.jpg delete mode 100644 spec/fixtures/images/1x1.webp delete mode 100644 spec/fixtures/images/An image of discobot in action.png delete mode 100644 spec/fixtures/rag/2-page.pdf delete mode 100644 spec/fixtures/rag/doc_with_metadata.txt delete mode 100644 spec/fixtures/rag/parsed_doc_with_metadata.txt delete mode 100644 spec/fixtures/search_meta/search.json delete mode 100644 spec/fixtures/search_meta/search_with_categories.json delete mode 100644 spec/fixtures/search_meta/site.json delete mode 100644 spec/jobs/regular/detect_translate_post_spec.rb delete mode 100644 spec/jobs/regular/detect_translate_topic_spec.rb delete mode 100644 spec/jobs/regular/digest_rag_upload_spec.rb delete mode 100644 spec/jobs/regular/fast_track_topic_gist_spec.rb delete mode 100644 spec/jobs/regular/generate_inferred_concepts_spec.rb delete mode 100644 spec/jobs/regular/generate_rag_embeddings_spec.rb delete mode 100644 spec/jobs/regular/localize_categories_spec.rb delete mode 100644 spec/jobs/regular/localize_posts_spec.rb delete mode 100644 spec/jobs/regular/localize_topics_spec.rb delete mode 100644 spec/jobs/regular/manage_embedding_def_search_index_spec.rb delete mode 100644 spec/jobs/regular/stream_composer_helper_spec.rb delete mode 100644 spec/jobs/regular/stream_discord_reply_spec.rb delete mode 100644 spec/jobs/regular/stream_discover_reply_spec.rb delete mode 100644 spec/jobs/regular/stream_post_helper_spec.rb delete mode 100644 spec/jobs/regular/stream_topic_ai_summary_spec.rb delete mode 100644 spec/jobs/scheduled/categories_locale_detection_backfill_spec.rb delete mode 100644 spec/jobs/scheduled/embeddings_backfill_spec.rb delete mode 100644 spec/jobs/scheduled/generate_concepts_from_popular_items_spec.rb delete mode 100644 spec/jobs/scheduled/post_localization_backfill_spec.rb delete mode 100644 spec/jobs/scheduled/posts_locale_detection_backfill_spec.rb delete mode 100644 spec/jobs/scheduled/remove_orphaned_embeddings_spec.rb delete mode 100644 spec/jobs/scheduled/sentiment_backfill_spec.rb delete mode 100644 spec/jobs/scheduled/summaries_backfill_spec.rb delete mode 100644 spec/jobs/scheduled/topics_locale_detection_backfill_spec.rb delete mode 100644 spec/jobs/shared_conversation_adjust_upload_security_spec.rb delete mode 100644 spec/lib/completions/anthropic_message_processor_spec.rb delete mode 100644 spec/lib/completions/cancel_manager_spec.rb delete mode 100644 spec/lib/completions/dialects/chat_gpt_spec.rb delete mode 100644 spec/lib/completions/dialects/claude_spec.rb delete mode 100644 spec/lib/completions/dialects/dialect_context.rb delete mode 100644 spec/lib/completions/dialects/dialect_spec.rb delete mode 100644 spec/lib/completions/dialects/gemini_spec.rb delete mode 100644 spec/lib/completions/dialects/mistral_spec.rb delete mode 100644 spec/lib/completions/dialects/nova_spec.rb delete mode 100644 spec/lib/completions/dialects/ollama_spec.rb delete mode 100644 spec/lib/completions/dialects/ollama_tools_spec.rb delete mode 100644 spec/lib/completions/dialects/open_ai_compatible_spec.rb delete mode 100644 spec/lib/completions/endpoints/anthropic_spec.rb delete mode 100644 spec/lib/completions/endpoints/aws_bedrock_spec.rb delete mode 100644 spec/lib/completions/endpoints/cohere_spec.rb delete mode 100644 spec/lib/completions/endpoints/endpoint_compliance.rb delete mode 100644 spec/lib/completions/endpoints/gemini_spec.rb delete mode 100644 spec/lib/completions/endpoints/hugging_face_spec.rb delete mode 100644 spec/lib/completions/endpoints/nova_spec.rb delete mode 100644 spec/lib/completions/endpoints/ollama_spec.rb delete mode 100644 spec/lib/completions/endpoints/open_ai_responses_api_spec.rb delete mode 100644 spec/lib/completions/endpoints/open_ai_spec.rb delete mode 100644 spec/lib/completions/endpoints/open_router_spec.rb delete mode 100644 spec/lib/completions/endpoints/samba_nova_spec.rb delete mode 100644 spec/lib/completions/endpoints/vllm_spec.rb delete mode 100644 spec/lib/completions/json_stream_decoder_spec.rb delete mode 100644 spec/lib/completions/llm_spec.rb delete mode 100644 spec/lib/completions/prompt_messages_builder_spec.rb delete mode 100644 spec/lib/completions/prompt_spec.rb delete mode 100644 spec/lib/completions/structured_output_spec.rb delete mode 100644 spec/lib/completions/tool_definition_spec.rb delete mode 100644 spec/lib/completions/upload_encoder_spec.rb delete mode 100644 spec/lib/completions/xml_tag_stripper_spec.rb delete mode 100644 spec/lib/completions/xml_tool_processor_spec.rb delete mode 100644 spec/lib/discord/bot/persona_replier_spec.rb delete mode 100644 spec/lib/discord/bot/search_spec.rb delete mode 100644 spec/lib/discourse_automation/automation_spec.rb delete mode 100644 spec/lib/discourse_automation/llm_persona_triage_spec.rb delete mode 100644 spec/lib/discourse_automation/llm_report_spec.rb delete mode 100644 spec/lib/discourse_automation/llm_tool_triage_spec.rb delete mode 100644 spec/lib/discourse_automation/llm_triage_spec.rb delete mode 100644 spec/lib/guardian_extensions_spec.rb delete mode 100644 spec/lib/inference/cloudflare_workers_ai_spec.rb delete mode 100644 spec/lib/inferred_concepts/applier_spec.rb delete mode 100644 spec/lib/inferred_concepts/finder_spec.rb delete mode 100644 spec/lib/inferred_concepts/manager_spec.rb delete mode 100644 spec/lib/modules/ai_bot/entry_point_spec.rb delete mode 100644 spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb delete mode 100644 spec/lib/modules/ai_bot/playground_spec.rb delete mode 100644 spec/lib/modules/ai_bot/site_setting_extension_spec.rb delete mode 100644 spec/lib/modules/ai_helper/assistant_spec.rb delete mode 100644 spec/lib/modules/ai_helper/chat_thread_titler_spec.rb delete mode 100644 spec/lib/modules/ai_helper/date_formatter_spec.rb delete mode 100644 spec/lib/modules/ai_helper/entry_point_spec.rb delete mode 100644 spec/lib/modules/ai_helper/painter_spec.rb delete mode 100644 spec/lib/modules/ai_helper/semantic_categorizer_spec.rb delete mode 100644 spec/lib/modules/ai_moderation/spam_scanner_spec.rb delete mode 100644 spec/lib/modules/automation/llm_triage_spec.rb delete mode 100644 spec/lib/modules/automation/report_context_generator_spec.rb delete mode 100644 spec/lib/modules/automation/report_runner_spec.rb delete mode 100644 spec/lib/modules/embeddings/entry_point_spec.rb delete mode 100644 spec/lib/modules/embeddings/jobs/generate_embeddings_spec.rb delete mode 100644 spec/lib/modules/embeddings/schema_spec.rb delete mode 100644 spec/lib/modules/embeddings/semantic_related_spec.rb delete mode 100644 spec/lib/modules/embeddings/semantic_search_spec.rb delete mode 100644 spec/lib/modules/embeddings/semantic_topic_query_spec.rb delete mode 100644 spec/lib/modules/embeddings/strategies/truncation_spec.rb delete mode 100644 spec/lib/modules/embeddings/vector_spec.rb delete mode 100644 spec/lib/modules/sentiment/emotion_filter_order_spec.rb delete mode 100644 spec/lib/modules/sentiment/entry_point_spec.rb delete mode 100644 spec/lib/modules/sentiment/jobs/regular/post_sentiment_analysis_spec.rb delete mode 100644 spec/lib/modules/sentiment/post_classification_spec.rb delete mode 100644 spec/lib/modules/summarization/entry_point_spec.rb delete mode 100644 spec/lib/modules/summarization/fold_content_spec.rb delete mode 100644 spec/lib/modules/summarization/strategies/hot_topic_gists_spec.rb delete mode 100644 spec/lib/modules/summarization/strategies/topic_summary_spec.rb delete mode 100644 spec/lib/personas/artifact_update_strategies/diff_spec.rb delete mode 100644 spec/lib/personas/bot_spec.rb delete mode 100644 spec/lib/personas/concept_deduplicator_spec.rb delete mode 100644 spec/lib/personas/concept_finder_spec.rb delete mode 100644 spec/lib/personas/concept_matcher_spec.rb delete mode 100644 spec/lib/personas/persona_spec.rb delete mode 100644 spec/lib/personas/question_consolidator_spec.rb delete mode 100644 spec/lib/personas/researcher_spec.rb delete mode 100644 spec/lib/personas/settings_explorer_spec.rb delete mode 100644 spec/lib/personas/sql_helper_spec.rb delete mode 100644 spec/lib/personas/tools/create_artifact_spec.rb delete mode 100644 spec/lib/personas/tools/create_image_spec.rb delete mode 100644 spec/lib/personas/tools/dall_e_spec.rb delete mode 100644 spec/lib/personas/tools/db_schema_spec.rb delete mode 100644 spec/lib/personas/tools/discourse_meta_search_spec.rb delete mode 100644 spec/lib/personas/tools/edit_image_spec.rb delete mode 100644 spec/lib/personas/tools/github_file_content_spec.rb delete mode 100644 spec/lib/personas/tools/github_pull_request_diff_spec.rb delete mode 100644 spec/lib/personas/tools/github_search_code_spec.rb delete mode 100644 spec/lib/personas/tools/github_search_files_spec.rb delete mode 100644 spec/lib/personas/tools/google_spec.rb delete mode 100644 spec/lib/personas/tools/image_spec.rb delete mode 100644 spec/lib/personas/tools/javascript_evaluator_spec.rb delete mode 100644 spec/lib/personas/tools/list_categories_spec.rb delete mode 100644 spec/lib/personas/tools/list_tags_spec.rb delete mode 100644 spec/lib/personas/tools/random_picker_spec.rb delete mode 100644 spec/lib/personas/tools/read_artifact_spec.rb delete mode 100644 spec/lib/personas/tools/read_spec.rb delete mode 100644 spec/lib/personas/tools/researcher_spec.rb delete mode 100644 spec/lib/personas/tools/search_settings_spec.rb delete mode 100644 spec/lib/personas/tools/search_spec.rb delete mode 100644 spec/lib/personas/tools/setting_context_spec.rb delete mode 100644 spec/lib/personas/tools/summarize_spec.rb delete mode 100644 spec/lib/personas/tools/time_spec.rb delete mode 100644 spec/lib/personas/tools/tool_spec.rb delete mode 100644 spec/lib/personas/tools/update_artifact_spec.rb delete mode 100644 spec/lib/personas/tools/web_browser_spec.rb delete mode 100644 spec/lib/translation/base_translator_spec.rb delete mode 100644 spec/lib/translation/category_locale_detector_spec.rb delete mode 100644 spec/lib/translation/category_localizer_spec.rb delete mode 100644 spec/lib/translation/content_splitter_spec.rb delete mode 100644 spec/lib/translation/entry_point_spec.rb delete mode 100644 spec/lib/translation/language_detector_spec.rb delete mode 100644 spec/lib/translation/post_detection_text_spec.rb delete mode 100644 spec/lib/translation/post_locale_detector_spec.rb delete mode 100644 spec/lib/translation/post_localizer_spec.rb delete mode 100644 spec/lib/translation/topic_locale_detector_spec.rb delete mode 100644 spec/lib/translation/topic_localizer_spec.rb delete mode 100644 spec/lib/utils/ai_staff_action_logger_spec.rb delete mode 100644 spec/lib/utils/best_effort_json_parser_spec.rb delete mode 100644 spec/lib/utils/diff_utils/hunk_diff_spec.rb delete mode 100644 spec/lib/utils/diff_utils/safety_checker_spec.rb delete mode 100644 spec/lib/utils/diff_utils/simple_diff_spec.rb delete mode 100644 spec/lib/utils/dns_srv_spec.rb delete mode 100644 spec/lib/utils/pdf_to_text_spec.rb delete mode 100644 spec/lib/utils/research/filter_spec.rb delete mode 100644 spec/lib/utils/research/llm_formatter_spec.rb delete mode 100644 spec/lib/utils/search_spec.rb delete mode 100644 spec/models/ai_artifact_key_value_spec.rb delete mode 100644 spec/models/ai_persona_multisite_spec.rb delete mode 100644 spec/models/ai_persona_spec.rb delete mode 100644 spec/models/ai_tool_spec.rb delete mode 100644 spec/models/inferred_concept_spec.rb delete mode 100644 spec/models/llm_model_spec.rb delete mode 100644 spec/models/llm_quota_spec.rb delete mode 100644 spec/models/llm_quota_usage_spec.rb delete mode 100644 spec/models/model_accuracy_spec.rb delete mode 100644 spec/models/rag_document_fragment_spec.rb delete mode 100644 spec/models/reviewable_ai_chat_message_spec.rb delete mode 100644 spec/models/reviewable_ai_post_spec.rb delete mode 100644 spec/models/shared_ai_conversation_spec.rb delete mode 100644 spec/models/user_option_spec.rb delete mode 100644 spec/plugin_helper.rb delete mode 100644 spec/plugin_spec.rb delete mode 100644 spec/reports/sentiment_analysis_spec.rb delete mode 100644 spec/requests/admin/ai_embeddings_controller_spec.rb delete mode 100644 spec/requests/admin/ai_features_controller_spec.rb delete mode 100644 spec/requests/admin/ai_llm_quotas_controller_spec.rb delete mode 100644 spec/requests/admin/ai_llms_controller_spec.rb delete mode 100644 spec/requests/admin/ai_personas_controller_spec.rb delete mode 100644 spec/requests/admin/ai_spam_controller_spec.rb delete mode 100644 spec/requests/admin/ai_tools_controller_spec.rb delete mode 100644 spec/requests/admin/ai_usage_controller_spec.rb delete mode 100644 spec/requests/admin/rag_document_fragments_controller_spec.rb delete mode 100644 spec/requests/admin/reviewable_controller_spec.rb delete mode 100644 spec/requests/ai_bot/artifact_key_values_controller_spec.rb delete mode 100644 spec/requests/ai_bot/artifacts_controller_spec.rb delete mode 100644 spec/requests/ai_bot/bot_controller_spec.rb delete mode 100644 spec/requests/ai_bot/shared_ai_conversations_controller_spec.rb delete mode 100644 spec/requests/ai_bot/topic_serialization_spec.rb delete mode 100644 spec/requests/ai_helper/assistant_controller_spec.rb delete mode 100644 spec/requests/discord/bot_controller_spec.rb delete mode 100644 spec/requests/embeddings/embeddings_controller_spec.rb delete mode 100644 spec/requests/sentiment/sentiment_controller_spec.rb delete mode 100644 spec/requests/summarization/chat_summary_controller_spec.rb delete mode 100644 spec/requests/summarization/summary_controller_spec.rb delete mode 100644 spec/requests/topic_spec.rb delete mode 100644 spec/serializers/ai_chat_channel_serializer_spec.rb delete mode 100644 spec/serializers/ai_features_persona_serializer_spec.rb delete mode 100644 spec/services/discourse_ai/persona_exporter_spec.rb delete mode 100644 spec/services/discourse_ai/persona_importer_spec.rb delete mode 100644 spec/services/discourse_ai/topic_summarization_spec.rb delete mode 100644 spec/services/problem_check/ai_llm_status_spec.rb delete mode 100644 spec/shared/inference/openai_embeddings_spec.rb delete mode 100644 spec/shared/inference/stability_generator_spec.rb delete mode 100644 spec/support/embeddings_generation_stubs.rb delete mode 100644 spec/support/sentiment_inference_stubs.rb delete mode 100644 spec/support/stable_diffusion_stubs.rb delete mode 100644 spec/system/admin_ai_features_spec.rb delete mode 100644 spec/system/admin_ai_persona_spec.rb delete mode 100644 spec/system/admin_dashboard_spec.rb delete mode 100644 spec/system/ai_artifact_key_value_api_spec.rb delete mode 100644 spec/system/ai_bot/ai_bot_helper_spec.rb delete mode 100644 spec/system/ai_bot/ai_share_conversation_spec.rb delete mode 100644 spec/system/ai_bot/artifact_spec.rb delete mode 100644 spec/system/ai_bot/homepage_spec.rb delete mode 100644 spec/system/ai_bot/persona_spec.rb delete mode 100644 spec/system/ai_bot/share_spec.rb delete mode 100644 spec/system/ai_bot/tool_spec.rb delete mode 100644 spec/system/ai_helper/ai_composer_helper_spec.rb delete mode 100644 spec/system/ai_helper/ai_image_caption_spec.rb delete mode 100644 spec/system/ai_helper/ai_post_helper_spec.rb delete mode 100644 spec/system/ai_helper/ai_proofreading_spec.rb delete mode 100644 spec/system/ai_helper/ai_split_topic_suggestion_spec.rb delete mode 100644 spec/system/ai_moderation/ai_spam_spec.rb delete mode 100644 spec/system/ai_user_preferences_spec.rb delete mode 100644 spec/system/core_features_spec.rb delete mode 100644 spec/system/embeddings/ai_embedding_definition_spec.rb delete mode 100644 spec/system/embeddings/semantic_search_spec.rb delete mode 100644 spec/system/llms/ai_llm_spec.rb delete mode 100644 spec/system/page_objects/components/ai_caption_popup.rb delete mode 100644 spec/system/page_objects/components/ai_composer_helper_menu.rb delete mode 100644 spec/system/page_objects/components/ai_pm_homepage.rb delete mode 100644 spec/system/page_objects/components/ai_post_helper_menu.rb delete mode 100644 spec/system/page_objects/components/ai_split_topic_suggester.rb delete mode 100644 spec/system/page_objects/components/ai_suggestion_dropdown.rb delete mode 100644 spec/system/page_objects/components/ai_summary_box.rb delete mode 100644 spec/system/page_objects/modals/ai_tool_test_modal.rb delete mode 100644 spec/system/page_objects/modals/diff_modal.rb delete mode 100644 spec/system/page_objects/pages/admin_ai_features.rb delete mode 100644 spec/system/page_objects/pages/discourse_ai/header.rb delete mode 100644 spec/system/page_objects/pages/user_preferences_ai.rb delete mode 100644 spec/system/summarization/chat_summarization_spec.rb delete mode 100644 spec/system/summarization/topic_summarization_spec.rb delete mode 100644 spec/tasks/backfill_spec.rb delete mode 100644 spec/tasks/scan_spec.rb delete mode 100644 stylelint.config.mjs delete mode 100644 svg-icons/icons-sprite.svg delete mode 100644 test/javascripts/.gitkeep delete mode 100644 test/javascripts/acceptance/post-helper-menu-test.js delete mode 100644 test/javascripts/acceptance/topic-summary-test.js delete mode 100644 test/javascripts/fixtures/ai-helper-prompts.js delete mode 100644 test/javascripts/integration/components/ai-indicator-wave-test.gjs delete mode 100644 test/javascripts/unit/lib/ai-streamer-test.js delete mode 100644 test/javascripts/unit/lib/diff-streamer-test.js delete mode 100644 test/javascripts/unit/lib/smooth-streamer-test.js delete mode 100644 test/javascripts/unit/lib/utilities-test.js delete mode 100644 test/javascripts/unit/models/ai-persona-test.js delete mode 100644 test/javascripts/unit/routes/discourse-ai-shared-conversation-show-route-test.js delete mode 100644 translator.yml diff --git a/Gemfile b/Gemfile deleted file mode 100644 index 31d8bf77..00000000 --- a/Gemfile +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org" - -group :development do - gem "rubocop-discourse" - gem "syntax_tree" -end diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index c0a7adea..00000000 --- a/Gemfile.lock +++ /dev/null @@ -1,100 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - activesupport (8.0.2) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) - ast (2.4.2) - base64 (0.2.0) - benchmark (0.4.0) - bigdecimal (3.1.9) - concurrent-ruby (1.3.5) - connection_pool (2.5.0) - drb (2.2.1) - i18n (1.14.7) - concurrent-ruby (~> 1.0) - json (2.10.2) - language_server-protocol (3.17.0.4) - lint_roller (1.1.0) - logger (1.6.6) - minitest (5.25.5) - parallel (1.26.3) - parser (3.3.7.1) - ast (~> 2.4.1) - racc - prettier_print (1.2.1) - racc (1.8.1) - rack (3.1.16) - rainbow (3.1.1) - regexp_parser (2.10.0) - rubocop (1.74.0) - json (~> 2.3) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.1.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.38.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.1) - parser (>= 3.3.1.0) - rubocop-capybara (2.22.1) - lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-discourse (3.12.1) - activesupport (>= 6.1) - lint_roller (>= 1.1.0) - rubocop (>= 1.73.2) - rubocop-capybara (>= 2.22.0) - rubocop-factory_bot (>= 2.27.0) - rubocop-rails (>= 2.30.3) - rubocop-rspec (>= 3.0.1) - rubocop-rspec_rails (>= 2.31.0) - rubocop-factory_bot (2.27.1) - lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-rails (2.30.3) - activesupport (>= 4.2.0) - lint_roller (~> 1.1) - rack (>= 1.1) - rubocop (>= 1.72.1, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rspec (3.5.0) - lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-rspec_rails (2.31.0) - lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-rspec (~> 3.5) - ruby-progressbar (1.13.0) - securerandom (0.4.1) - syntax_tree (6.2.0) - prettier_print (>= 1.2.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) - -PLATFORMS - ruby - -DEPENDENCIES - rubocop-discourse - syntax_tree - -BUNDLED WITH - 2.6.6 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 8d90b864..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) Civilized Discourse Construction Kit, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/README.md b/README.md index a809df1d..bcb4d9d0 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,3 @@ -# **Discourse AI** Plugin +# Discourse AI is no shipped with Discourse core -> [!IMPORTANT] -> This plugin has now been bundled into Discourse core. See: https://meta.discourse.org/t/bundling-more-popular-plugins-with-discourse-core/373574 - -**Plugin Summary** - -For more information, please see: https://meta.discourse.org/t/discourse-ai/259214?u=falco - -### Evals - -The directory `evals` contains AI evals for the Discourse AI plugin. -You may create a local config by copying `config/eval-llms.yml` to `config/eval-llms.local.yml` and modifying the values. - -To run them use: - -cd evals -./run --help - -``` -Usage: evals/run [options] - -e, --eval NAME Name of the evaluation to run - --list-models List models - -m, --model NAME Model to evaluate (will eval all models if not specified) - -l, --list List evals -``` - -To run evals you will need to configure API keys in your environment: - -OPENAI_API_KEY=your_openai_api_key -ANTHROPIC_API_KEY=your_anthropic_api_key -GEMINI_API_KEY=your_gemini_api_key +see: https://github.com/discourse/discourse/tree/main/plugins/discourse-ai diff --git a/about.json b/about.json deleted file mode 100644 index 0d1911d8..00000000 --- a/about.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "tests": { - "requiredPlugins": [ - "https://github.com/discourse/discourse-prometheus", - "https://github.com/discourse/discourse-solved.git" - ] - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-edit.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-edit.js deleted file mode 100644 index 46a7bc30..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-edit.js +++ /dev/null @@ -1,21 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class AdminPluginsShowDiscourseAiEmbeddingsEdit extends DiscourseRoute { - async model(params) { - const allEmbeddings = this.modelFor( - "adminPlugins.show.discourse-ai-embeddings" - ); - const id = parseInt(params.id, 10); - const record = allEmbeddings.findBy("id", id); - record.provider_params = record.provider_params || {}; - return record; - } - - setupController(controller, model) { - super.setupController(controller, model); - controller.set( - "allEmbeddings", - this.modelFor("adminPlugins.show.discourse-ai-embeddings") - ); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-new.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-new.js deleted file mode 100644 index ea8a9239..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings-new.js +++ /dev/null @@ -1,17 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class AdminPluginsShowDiscourseAiEmbeddingsNew extends DiscourseRoute { - async model() { - const record = this.store.createRecord("ai-embedding"); - record.provider_params = {}; - return record; - } - - setupController(controller, model) { - super.setupController(controller, model); - controller.set( - "allEmbeddings", - this.modelFor("adminPlugins.show.discourse-ai-embeddings") - ); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings.js deleted file mode 100644 index 475e6f88..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-embeddings.js +++ /dev/null @@ -1,7 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiAiEmbeddingsRoute extends DiscourseRoute { - model() { - return this.store.findAll("ai-embedding"); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js deleted file mode 100644 index a0eac213..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js +++ /dev/null @@ -1,35 +0,0 @@ -import { action } from "@ember/object"; -import { ajax } from "discourse/lib/ajax"; -import DiscourseRoute from "discourse/routes/discourse"; -import SiteSetting from "admin/models/site-setting"; - -export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRoute { - async model(params) { - const allFeatures = this.modelFor( - "adminPlugins.show.discourse-ai-features" - ); - const id = parseInt(params.id, 10); - const currentFeature = allFeatures.find((feature) => feature.id === id); - - const { site_settings } = await ajax("/admin/config/site_settings.json", { - data: { - filter_area: `ai-features/${currentFeature.module_name}`, - plugin: "discourse-ai", - category: "discourse_ai", - }, - }); - - currentFeature.feature_settings = site_settings.map((setting) => - SiteSetting.create(setting) - ); - - return currentFeature; - } - - @action - willTransition() { - // site settings may amend if a feature is enabled or disabled, so refresh the model - // even on back button - this.router.refresh("adminPlugins.show.discourse-ai-features"); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features.js deleted file mode 100644 index 72b17894..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features.js +++ /dev/null @@ -1,10 +0,0 @@ -import { service } from "@ember/service"; -import DiscourseRoute from "discourse/routes/discourse"; - -export default class AdminPluginsShowDiscourseAiFeatures extends DiscourseRoute { - @service store; - - async model() { - return this.store.findAll("ai-feature"); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-edit.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-edit.js deleted file mode 100644 index f7b5de4c..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-edit.js +++ /dev/null @@ -1,28 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class AdminPluginsShowDiscourseAiLlmsEdit extends DiscourseRoute { - async model(params) { - const id = parseInt(params.id, 10); - - if (id < 0) { - // You shouldn't be able to access the edit page - // if the model is seeded - return this.router.transitionTo( - "adminPlugins.show.discourse-ai-llms.index" - ); - } - - const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms"); - const record = allLlms.findBy("id", id); - record.provider_params = record.provider_params || {}; - return record; - } - - setupController(controller, model) { - super.setupController(controller, model); - controller.set( - "allLlms", - this.modelFor("adminPlugins.show.discourse-ai-llms") - ); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-new.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-new.js deleted file mode 100644 index a584097e..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-new.js +++ /dev/null @@ -1,25 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class AdminPluginsShowDiscourseAiLlmsNew extends DiscourseRoute { - queryParams = { - llmTemplate: { refreshModel: true }, - }; - - async model() { - const record = this.store.createRecord("ai-llm"); - record.provider_params = {}; - return record; - } - - setupController(controller, model) { - super.setupController(controller, model); - controller.set( - "allLlms", - this.modelFor("adminPlugins.show.discourse-ai-llms") - ); - controller.set( - "llmTemplate", - this.paramsFor(this.routeName).llmTemplate || null - ); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms.js deleted file mode 100644 index 21f8f44b..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms.js +++ /dev/null @@ -1,7 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiAiLlmsRoute extends DiscourseRoute { - model() { - return this.store.findAll("ai-llm"); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-edit.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-edit.js deleted file mode 100644 index 8b5fdd02..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-edit.js +++ /dev/null @@ -1,19 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class AdminPluginsShowDiscourseAiPersonasEdit extends DiscourseRoute { - async model(params) { - const allPersonas = this.modelFor( - "adminPlugins.show.discourse-ai-personas" - ); - const id = parseInt(params.id, 10); - return allPersonas.findBy("id", id); - } - - setupController(controller, model) { - super.setupController(controller, model); - controller.set( - "allPersonas", - this.modelFor("adminPlugins.show.discourse-ai-personas") - ); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-new.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-new.js deleted file mode 100644 index 826799e9..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas-new.js +++ /dev/null @@ -1,25 +0,0 @@ -import { AUTO_GROUPS } from "discourse/lib/constants"; -import DiscourseRoute from "discourse/routes/discourse"; - -export default class AdminPluginsShowDiscourseAiPersonasNew extends DiscourseRoute { - async model() { - const record = this.store.createRecord("ai-persona"); - record.set("allowed_group_ids", [AUTO_GROUPS.trust_level_0.id]); - record.set("rag_uploads", []); - // these match the defaults on the table - record.set("rag_chunk_tokens", 374); - record.set("rag_chunk_overlap_tokens", 10); - record.set("rag_conversation_chunks", 10); - record.set("allow_personal_messages", true); - record.set("tool_details", false); - return record; - } - - setupController(controller, model) { - super.setupController(controller, model); - controller.set( - "allPersonas", - this.modelFor("adminPlugins.show.discourse-ai-personas") - ); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas.js deleted file mode 100644 index 7455e4d5..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-personas.js +++ /dev/null @@ -1,7 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiAiPersonasRoute extends DiscourseRoute { - model() { - return this.store.findAll("ai-persona"); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-spam.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-spam.js deleted file mode 100644 index 66fd3cf4..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-spam.js +++ /dev/null @@ -1,11 +0,0 @@ -import { service } from "@ember/service"; -import { ajax } from "discourse/lib/ajax"; -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiSpamRoute extends DiscourseRoute { - @service store; - - model() { - return ajax("/admin/plugins/discourse-ai/ai-spam.json"); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-edit.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-edit.js deleted file mode 100644 index 65b3f7f3..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-edit.js +++ /dev/null @@ -1,20 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiToolsEditRoute extends DiscourseRoute { - async model(params) { - const allTools = this.modelFor("adminPlugins.show.discourse-ai-tools"); - const id = parseInt(params.id, 10); - - return allTools.find((tool) => tool.id === id); - } - - setupController(controller) { - super.setupController(...arguments); - const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools"); - - controller.set("allTools", toolsModel); - controller.set("presets", toolsModel.resultSetMeta.presets); - controller.set("llms", toolsModel.resultSetMeta.llms); - controller.set("settings", toolsModel.resultSetMeta.settings); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-new.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-new.js deleted file mode 100644 index 463ae21a..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-new.js +++ /dev/null @@ -1,21 +0,0 @@ -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiToolsNewRoute extends DiscourseRoute { - beforeModel(transition) { - this.preset = transition.to.queryParams.presetId || "empty_tool"; - } - - async model() { - return this.store.createRecord("ai-tool"); - } - - setupController(controller) { - super.setupController(...arguments); - const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools"); - controller.set("allTools", toolsModel); - controller.set("presets", toolsModel.resultSetMeta.presets); - controller.set("llms", toolsModel.resultSetMeta.llms); - controller.set("settings", toolsModel.resultSetMeta.settings); - controller.set("selectedPreset", this.preset); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools.js deleted file mode 100644 index b8d1117f..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools.js +++ /dev/null @@ -1,10 +0,0 @@ -import { service } from "@ember/service"; -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiToolsRoute extends DiscourseRoute { - @service store; - - model() { - return this.store.findAll("ai-tool"); - } -} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-usage.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-usage.js deleted file mode 100644 index 08a0399c..00000000 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-usage.js +++ /dev/null @@ -1,11 +0,0 @@ -import { service } from "@ember/service"; -import { ajax } from "discourse/lib/ajax"; -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiUsageRoute extends DiscourseRoute { - @service store; - - model() { - return ajax("/admin/plugins/discourse-ai/ai-usage.json"); - } -} diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/edit.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/edit.hbs deleted file mode 100644 index 8ec8776f..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/edit.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/index.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/index.hbs deleted file mode 100644 index 8226d03c..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/index.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/new.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/new.hbs deleted file mode 100644 index 8ec8776f..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-embeddings/new.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs deleted file mode 100644 index 9513e024..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs +++ /dev/null @@ -1,24 +0,0 @@ -import RouteTemplate from "ember-route-template"; -import BackButton from "discourse/components/back-button"; -import SiteSettingComponent from "admin/components/site-setting"; - -export default RouteTemplate( - -); diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/index.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/index.hbs deleted file mode 100644 index d0193ebe..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/index.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/edit.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/edit.hbs deleted file mode 100644 index 77f3b0f3..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/edit.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/index.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/index.hbs deleted file mode 100644 index e1ab7f35..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/index.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/new.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/new.hbs deleted file mode 100644 index cfc02f05..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-llms/new.hbs +++ /dev/null @@ -1,5 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/edit.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/edit.hbs deleted file mode 100644 index a3173af4..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/edit.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/index.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/index.hbs deleted file mode 100644 index 74bb4581..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/index.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/new.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/new.hbs deleted file mode 100644 index a3173af4..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-personas/new.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-spam.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-spam.hbs deleted file mode 100644 index f9946ef9..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-spam.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/edit.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/edit.hbs deleted file mode 100644 index 444f6966..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/edit.hbs +++ /dev/null @@ -1,9 +0,0 @@ -
- -
\ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/index.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/index.hbs deleted file mode 100644 index 1b0dbe81..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/index.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/new.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/new.hbs deleted file mode 100644 index cc036e76..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/new.hbs +++ /dev/null @@ -1,10 +0,0 @@ -
- -
\ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-usage.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-usage.hbs deleted file mode 100644 index e1592fc9..00000000 --- a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-usage.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/controllers/discourse_ai/admin/ai_embeddings_controller.rb b/app/controllers/discourse_ai/admin/ai_embeddings_controller.rb deleted file mode 100644 index ceea81af..00000000 --- a/app/controllers/discourse_ai/admin/ai_embeddings_controller.rb +++ /dev/null @@ -1,200 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class AiEmbeddingsController < ::Admin::AdminController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - def index - embedding_defs = EmbeddingDefinition.all.order(:display_name) - - render json: { - ai_embeddings: - ActiveModel::ArraySerializer.new( - embedding_defs, - each_serializer: AiEmbeddingDefinitionSerializer, - root: false, - ).as_json, - meta: { - provider_params: EmbeddingDefinition.provider_params, - providers: EmbeddingDefinition.provider_names, - distance_functions: EmbeddingDefinition.distance_functions, - tokenizers: - EmbeddingDefinition.tokenizer_names.map { |tn| - { id: tn, name: tn.split("::").last } - }, - presets: EmbeddingDefinition.presets, - }, - } - end - - def new - end - - def edit - embedding_def = EmbeddingDefinition.find(params[:id]) - render json: AiEmbeddingDefinitionSerializer.new(embedding_def) - end - - def create - embedding_def = EmbeddingDefinition.new(ai_embeddings_params) - - if embedding_def.save - log_ai_embedding_creation(embedding_def) - render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created - else - render_json_error embedding_def - end - end - - def update - embedding_def = EmbeddingDefinition.find(params[:id]) - - if embedding_def.seeded? - return( - render_json_error(I18n.t("discourse_ai.embeddings.cannot_edit_builtin"), status: 403) - ) - end - - initial_attributes = embedding_def.attributes.dup - - if embedding_def.update(ai_embeddings_params.except(:dimensions)) - log_ai_embedding_update(embedding_def, initial_attributes) - render json: AiEmbeddingDefinitionSerializer.new(embedding_def) - else - render_json_error embedding_def - end - end - - def destroy - embedding_def = EmbeddingDefinition.find(params[:id]) - - if embedding_def.seeded? - return( - render_json_error(I18n.t("discourse_ai.embeddings.cannot_edit_builtin"), status: 403) - ) - end - - if embedding_def.id == SiteSetting.ai_embeddings_selected_model.to_i - return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409) - end - - embedding_details = { - embedding_id: embedding_def.id, - display_name: embedding_def.display_name, - provider: embedding_def.provider, - dimensions: embedding_def.dimensions, - subject: embedding_def.display_name, - } - - if embedding_def.destroy - log_ai_embedding_deletion(embedding_details) - head :no_content - else - render_json_error embedding_def - end - end - - def test - RateLimiter.new( - current_user, - "ai_embeddings_test_#{current_user.id}", - 3, - 1.minute, - ).performed! - - embedding_def = EmbeddingDefinition.new(ai_embeddings_params) - DiscourseAi::Embeddings::Vector.new(embedding_def).vector_from("this is a test") - - render json: { success: true } - rescue Net::HTTPBadResponse => e - render json: { success: false, error: e.message } - end - - private - - def ai_embeddings_params - permitted = - params.require(:ai_embedding).permit( - :display_name, - :dimensions, - :max_sequence_length, - :pg_function, - :provider, - :url, - :api_key, - :tokenizer_class, - :embed_prompt, - :search_prompt, - :matryoshka_dimensions, - ) - - extra_field_names = EmbeddingDefinition.provider_params.dig(permitted[:provider]&.to_sym) - if extra_field_names.present? - received_prov_params = - params.dig(:ai_embedding, :provider_params)&.slice(*extra_field_names.keys) - - if received_prov_params.present? - permitted[:provider_params] = received_prov_params.permit! - end - end - - permitted - end - - def ai_embeddings_logger_fields - { - display_name: { - }, - provider: { - }, - dimensions: { - }, - url: { - }, - tokenizer_class: { - }, - max_sequence_length: { - }, - embed_prompt: { - type: :large_text, - }, - search_prompt: { - type: :large_text, - }, - matryoshka_dimensions: { - }, - api_key: { - type: :sensitive, - }, - # JSON fields should be tracked as simple changes - json_fields: [:provider_params], - } - end - - def log_ai_embedding_creation(embedding_def) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name } - logger.log_creation("embedding", embedding_def, ai_embeddings_logger_fields, entity_details) - end - - def log_ai_embedding_update(embedding_def, initial_attributes) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name } - - logger.log_update( - "embedding", - embedding_def, - initial_attributes, - ai_embeddings_logger_fields, - entity_details, - ) - end - - def log_ai_embedding_deletion(embedding_details) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - logger.log_deletion("embedding", embedding_details) - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/ai_features_controller.rb b/app/controllers/discourse_ai/admin/ai_features_controller.rb deleted file mode 100644 index d66e5b8b..00000000 --- a/app/controllers/discourse_ai/admin/ai_features_controller.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class AiFeaturesController < ::Admin::AdminController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - def index - render json: serialize_modules(DiscourseAi::Configuration::Module.all) - end - - def edit - raise Discourse::InvalidParameters.new(:id) if params[:id].blank? - - a_module = DiscourseAi::Configuration::Module.find_by(id: params[:id].to_i) - - render json: serialize_module(a_module) - end - - private - - def serialize_modules(modules) - modules.map { |a_module| serialize_module(a_module) } - end - - def serialize_module(a_module) - return nil if a_module.blank? - - { - id: a_module.id, - module_name: a_module.name, - module_enabled: a_module.enabled?, - features: a_module.features.map { |f| serialize_feature(f) }, - } - end - - def serialize_feature(feature) - { - name: feature.name, - personas: feature.persona_ids.map { |id| serialize_persona(persona_id_obj_hash[id]) }, - llm_models: - feature.llm_models.map do |llm_model| - { id: llm_model.id, name: llm_model.display_name } - end, - enabled: feature.enabled?, - } - end - - def serialize_persona(persona) - return nil if persona.blank? - - serialize_data(persona, AiFeaturesPersonaSerializer, root: false) - end - - private - - def persona_id_obj_hash - @persona_id_obj_hash ||= - begin - ids = DiscourseAi::Configuration::Feature.all.map(&:persona_ids).flatten.uniq - AiPersona.where(id: ids).index_by(&:id) - end - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/ai_llm_quotas_controller.rb b/app/controllers/discourse_ai/admin/ai_llm_quotas_controller.rb deleted file mode 100644 index b5e89c8c..00000000 --- a/app/controllers/discourse_ai/admin/ai_llm_quotas_controller.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class AiLlmQuotasController < ::Admin::AdminController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - def index - quotas = LlmQuota.includes(:group) - - render json: { - quotas: - ActiveModel::ArraySerializer.new(quotas, each_serializer: LlmQuotaSerializer), - } - end - - def create - quota = LlmQuota.new(quota_params) - - if quota.save - render json: LlmQuotaSerializer.new(quota), status: :created - else - render_json_error quota - end - end - - def update - quota = LlmQuota.find(params[:id]) - - if quota.update(quota_params) - render json: LlmQuotaSerializer.new(quota) - else - render_json_error quota - end - end - - def destroy - quota = LlmQuota.find(params[:id]) - quota.destroy! - - head :no_content - rescue ActiveRecord::RecordNotFound - render json: { error: I18n.t("not_found") }, status: 404 - end - - private - - def quota_params - params.require(:quota).permit( - :group_id, - :llm_model_id, - :max_tokens, - :max_usages, - :duration_seconds, - ) - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/ai_llms_controller.rb b/app/controllers/discourse_ai/admin/ai_llms_controller.rb deleted file mode 100644 index 68cb24c7..00000000 --- a/app/controllers/discourse_ai/admin/ai_llms_controller.rb +++ /dev/null @@ -1,293 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class AiLlmsController < ::Admin::AdminController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - def index - llms = LlmModel.all.includes(:llm_quotas).order(:display_name) - - render json: { - ai_llms: - ActiveModel::ArraySerializer.new( - llms, - each_serializer: LlmModelSerializer, - root: false, - scope: { - llm_usage: DiscourseAi::Configuration::LlmEnumerator.global_usage, - }, - ).as_json, - meta: { - provider_params: LlmModel.provider_params, - presets: DiscourseAi::Completions::Llm.presets, - providers: DiscourseAi::Completions::Llm.provider_names, - tokenizers: - DiscourseAi::Completions::Llm.tokenizer_names.map { |tn| - { id: tn, name: tn.split("::").last } - }, - }, - } - end - - def new - end - - def edit - llm_model = LlmModel.find(params[:id]) - render json: LlmModelSerializer.new(llm_model) - end - - def create - llm_model = LlmModel.new(ai_llm_params) - - # we could do nested attributes but the mechanics are not ideal leading - # to lots of complex debugging, this is simpler - quota_params.each { |quota| llm_model.llm_quotas.build(quota) } if quota_params - - if llm_model.save - llm_model.toggle_companion_user - log_llm_model_creation(llm_model) - render json: LlmModelSerializer.new(llm_model), status: :created - else - render_json_error llm_model - end - end - - def update - llm_model = LlmModel.find(params[:id]) - - # Capture initial state for logging - initial_attributes = llm_model.attributes.dup - initial_quotas = llm_model.llm_quotas.map(&:attributes) - - if params[:ai_llm].key?(:llm_quotas) - if quota_params - existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id) - new_quota_group_ids = quota_params.map { |q| q[:group_id] } - - llm_model - .llm_quotas - .where(group_id: existing_quota_group_ids - new_quota_group_ids) - .destroy_all - - quota_params.each do |quota_param| - quota = llm_model.llm_quotas.find_or_initialize_by(group_id: quota_param[:group_id]) - quota.update!(quota_param) - end - else - llm_model.llm_quotas.destroy_all - end - end - - if llm_model.seeded? - return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403) - end - - if llm_model.update(ai_llm_params(updating: llm_model)) - llm_model.toggle_companion_user - log_llm_model_update(llm_model, initial_attributes, initial_quotas) - render json: LlmModelSerializer.new(llm_model) - else - render_json_error llm_model - end - end - - def destroy - llm_model = LlmModel.find(params[:id]) - - if llm_model.seeded? - return render_json_error(I18n.t("discourse_ai.llm.cannot_delete_builtin"), status: 403) - end - - in_use_by = DiscourseAi::Configuration::LlmValidator.new.modules_using(llm_model) - - if !in_use_by.empty? - return( - render_json_error( - I18n.t( - "discourse_ai.llm.delete_failed", - settings: in_use_by.join(", "), - count: in_use_by.length, - ), - status: 409, - ) - ) - end - - # Capture model details for logging before destruction - model_details = { - model_id: llm_model.id, - display_name: llm_model.display_name, - name: llm_model.name, - provider: llm_model.provider, - } - - # Clean up companion users - llm_model.enabled_chat_bot = false - llm_model.toggle_companion_user - - if llm_model.destroy - log_llm_model_deletion(model_details) - head :no_content - else - render_json_error llm_model - end - end - - def test - RateLimiter.new(current_user, "llm_test_#{current_user.id}", 3, 1.minute).performed! - - llm_model = LlmModel.new(ai_llm_params) - - DiscourseAi::Configuration::LlmValidator.new.run_test(llm_model) - - render json: { success: true } - rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e - render json: { success: false, error: e.message } - end - - private - - def quota_params - if params[:ai_llm][:llm_quotas].present? - params[:ai_llm][:llm_quotas].map do |quota| - mapped = {} - mapped[:group_id] = quota[:group_id].to_i - mapped[:max_tokens] = quota[:max_tokens].to_i if quota[:max_tokens].present? - mapped[:max_usages] = quota[:max_usages].to_i if quota[:max_usages].present? - mapped[:duration_seconds] = quota[:duration_seconds].to_i - mapped - end - end - end - - def ai_llm_params(updating: nil) - return {} if params[:ai_llm].blank? - - permitted = - params.require(:ai_llm).permit( - :display_name, - :name, - :provider, - :tokenizer, - :max_prompt_tokens, - :max_output_tokens, - :api_key, - :enabled_chat_bot, - :vision_enabled, - :input_cost, - :cached_input_cost, - :output_cost, - ) - - provider = updating ? updating.provider : permitted[:provider] - permit_url = provider != LlmModel::BEDROCK_PROVIDER_NAME - - new_url = params.dig(:ai_llm, :url) - permitted[:url] = new_url if permit_url && new_url - - extra_field_names = LlmModel.provider_params.dig(provider&.to_sym) - if extra_field_names.present? - received_prov_params = - params.dig(:ai_llm, :provider_params)&.slice(*extra_field_names.keys) - - if received_prov_params.present? - received_prov_params.each do |pname, value| - if extra_field_names[pname.to_sym] == :checkbox - received_prov_params[pname] = ActiveModel::Type::Boolean.new.cast(value) - end - end - - permitted[:provider_params] = received_prov_params.permit! - end - end - - permitted - end - - def ai_llm_logger_fields - { - display_name: { - }, - name: { - }, - provider: { - }, - tokenizer: { - }, - url: { - }, - max_prompt_tokens: { - }, - max_output_tokens: { - }, - enabled_chat_bot: { - }, - vision_enabled: { - }, - api_key: { - type: :sensitive, - }, - input_cost: { - }, - output_cost: { - }, - # JSON fields should be tracked as simple changes - json_fields: [:provider_params], - } - end - - def log_llm_model_creation(llm_model) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - entity_details = { model_id: llm_model.id, subject: llm_model.display_name } - - # Add quota information as a special case - if llm_model.llm_quotas.any? - entity_details[:quotas] = llm_model - .llm_quotas - .map do |quota| - "Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s" - end - .join("; ") - end - - logger.log_creation("llm_model", llm_model, ai_llm_logger_fields, entity_details) - end - - def log_llm_model_update(llm_model, initial_attributes, initial_quotas) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - entity_details = { model_id: llm_model.id, subject: llm_model.display_name } - - # Track quota changes separately as they're a special case - current_quotas = llm_model.llm_quotas.reload.map(&:attributes) - if initial_quotas != current_quotas - initial_quota_summary = - initial_quotas - .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } - .join("; ") - current_quota_summary = - current_quotas - .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } - .join("; ") - entity_details[:quotas_changed] = true - entity_details[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}" - end - - logger.log_update( - "llm_model", - llm_model, - initial_attributes, - ai_llm_logger_fields, - entity_details, - ) - end - - def log_llm_model_deletion(model_details) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - model_details[:subject] = model_details[:display_name] - logger.log_deletion("llm_model", model_details) - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb deleted file mode 100644 index 56c96d56..00000000 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ /dev/null @@ -1,403 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class AiPersonasController < ::Admin::AdminController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - before_action :find_ai_persona, only: %i[edit update destroy create_user export] - - def index - ai_personas = - AiPersona - .ordered - .includes(:user, :uploads) - .map { |persona| LocalizedAiPersonaSerializer.new(persona, root: false) } - - tools = - DiscourseAi::Personas::Persona.all_available_tools.map do |tool| - AiToolSerializer.new(tool, root: false) - end - - AiTool - .where(enabled: true) - .each do |tool| - tools << { - id: "custom-#{tool.id}", - name: - I18n.t( - "discourse_ai.tools.custom_name", - name: tool.name.capitalize, - tool_name: tool.tool_name, - ), - } - end - - llms = - DiscourseAi::Configuration::LlmEnumerator.values_for_serialization( - allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map, - ) - - render json: { - ai_personas: ai_personas, - meta: { - tools: tools, - llms: llms, - settings: { - rag_images_enabled: SiteSetting.ai_rag_images_enabled, - }, - }, - } - end - - def new - end - - def edit - render json: LocalizedAiPersonaSerializer.new(@ai_persona) - end - - def create - ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads)) - if ai_persona.save - RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids) - log_ai_persona_creation(ai_persona) - - render json: { - ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false), - }, - status: :created - else - render_json_error ai_persona - end - end - - def create_user - user = @ai_persona.create_user! - render json: BasicUserSerializer.new(user, root: "user") - end - - def update - initial_attributes = @ai_persona.attributes.dup - - if @ai_persona.update(ai_persona_params.except(:rag_uploads)) - RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids) - log_ai_persona_update(@ai_persona, initial_attributes) - - render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false) - else - render_json_error @ai_persona - end - end - - def destroy - persona_details = { - persona_id: @ai_persona.id, - name: @ai_persona.name, - description: @ai_persona.description, - } - - if @ai_persona.destroy - log_ai_persona_deletion(persona_details) - head :no_content - else - render_json_error @ai_persona - end - end - - def export - persona = AiPersona.find(params[:id]) - exporter = DiscourseAi::PersonaExporter.new(persona: persona) - - response.headers[ - "Content-Disposition" - ] = "attachment; filename=\"#{persona.name.parameterize}.json\"" - - render json: exporter.export - end - - def import - name = params.dig(:persona, :name) - existing_persona = AiPersona.find_by(name: name) - force_update = params[:force].present? && params[:force].to_s.downcase == "true" - - begin - importer = DiscourseAi::PersonaImporter.new(json: params.to_unsafe_h) - - if existing_persona && force_update - initial_attributes = existing_persona.attributes.dup - persona = importer.import!(overwrite: true) - log_ai_persona_update(persona, initial_attributes) - render json: LocalizedAiPersonaSerializer.new(persona, root: false) - else - persona = importer.import! - log_ai_persona_creation(persona) - render json: LocalizedAiPersonaSerializer.new(persona, root: false), status: :created - end - rescue DiscourseAi::PersonaImporter::ImportError => e - render_json_error e.message, status: :unprocessable_entity - rescue StandardError => e - Rails.logger.error("AI Persona import failed: #{e.message}") - render_json_error "Import failed: #{e.message}", status: :unprocessable_entity - end - end - - def stream_reply - persona = - AiPersona.find_by(name: params[:persona_name]) || - AiPersona.find_by(id: params[:persona_id]) - return render_json_error(I18n.t("discourse_ai.errors.persona_not_found")) if persona.nil? - - return render_json_error(I18n.t("discourse_ai.errors.persona_disabled")) if !persona.enabled - - if persona.default_llm.blank? - return render_json_error(I18n.t("discourse_ai.errors.no_default_llm")) - end - - if params[:query].blank? - return render_json_error(I18n.t("discourse_ai.errors.no_query_specified")) - end - - if !persona.user_id - return render_json_error(I18n.t("discourse_ai.errors.no_user_for_persona")) - end - - if !params[:username] && !params[:user_unique_id] - return render_json_error(I18n.t("discourse_ai.errors.no_user_specified")) - end - - user = nil - - if params[:username] - user = User.find_by_username(params[:username]) - return render_json_error(I18n.t("discourse_ai.errors.user_not_found")) if user.nil? - elsif params[:user_unique_id] - user = stage_user - end - - raise Discourse::NotFound if user.nil? - - topic_id = params[:topic_id].to_i - topic = nil - - if topic_id > 0 - topic = Topic.find(topic_id) - - if topic.topic_allowed_users.where(user_id: user.id).empty? - return render_json_error(I18n.t("discourse_ai.errors.user_not_allowed")) - end - end - - hijack = request.env["rack.hijack"] - io = hijack.call - - DiscourseAi::AiBot::ResponseHttpStreamer.queue_streamed_reply( - io: io, - persona: persona, - user: user, - topic: topic, - query: params[:query].to_s, - custom_instructions: params[:custom_instructions].to_s, - current_user: current_user, - ) - end - - private - - AI_STREAM_CONVERSATION_UNIQUE_ID = "ai-stream-conversation-unique-id" - - def stage_user - unique_id = params[:user_unique_id].to_s - field = UserCustomField.find_by(name: AI_STREAM_CONVERSATION_UNIQUE_ID, value: unique_id) - - if field - field.user - else - preferred_username = params[:preferred_username] - username = UserNameSuggester.suggest(preferred_username || unique_id) - - user = - User.new( - username: username, - email: "#{SecureRandom.hex}@invalid.com", - staged: true, - active: false, - ) - user.custom_fields[AI_STREAM_CONVERSATION_UNIQUE_ID] = unique_id - user.save! - user - end - end - - def find_ai_persona - @ai_persona = AiPersona.find(params[:id]) - end - - def attached_upload_ids - ai_persona_params[:rag_uploads].to_a.map { |h| h[:id] } - end - - def ai_persona_params - permitted = - params.require(:ai_persona).permit( - :name, - :description, - :enabled, - :system_prompt, - :priority, - :top_p, - :temperature, - :default_llm_id, - :user_id, - :max_context_posts, - :vision_enabled, - :vision_max_pixels, - :rag_chunk_tokens, - :rag_chunk_overlap_tokens, - :rag_conversation_chunks, - :rag_llm_model_id, - :question_consolidator_llm_id, - :allow_chat_channel_mentions, - :allow_chat_direct_messages, - :allow_topic_mentions, - :allow_personal_messages, - :tool_details, - :forced_tool_count, - :force_default_llm, - allowed_group_ids: [], - rag_uploads: [:id], - ) - - if tools = params.dig(:ai_persona, :tools) - permitted[:tools] = permit_tools(tools) - end - - if response_format = params.dig(:ai_persona, :response_format) - permitted[:response_format] = permit_response_format(response_format) - end - - if examples = params.dig(:ai_persona, :examples) - permitted[:examples] = permit_examples(examples) - end - - permitted - end - - def permit_tools(tools) - return [] if !tools.is_a?(Array) - - tools.filter_map do |tool, options, force_tool| - break nil if !tool.is_a?(String) - options&.permit! if options && options.is_a?(ActionController::Parameters) - - # this is simpler from a storage perspective, 1 way to store tools - [tool, options, !!force_tool] - end - end - - def permit_response_format(response_format) - return [] if !response_format.is_a?(Array) - - response_format.map do |element| - if element && element.is_a?(ActionController::Parameters) - element.permit! - else - false - end - end - end - - def permit_examples(examples) - return [] if !examples.is_a?(Array) - - examples.map { |example_arr| example_arr.take(2).map(&:to_s) } - end - - def ai_persona_logger_fields - { - name: { - }, - description: { - }, - enabled: { - }, - priority: { - }, - system_prompt: { - type: :large_text, - }, - default_llm_id: { - }, - temperature: { - }, - top_p: { - }, - user_id: { - }, - max_context_posts: { - }, - vision_enabled: { - }, - vision_max_pixels: { - }, - rag_chunk_tokens: { - }, - rag_chunk_overlap_tokens: { - }, - rag_conversation_chunks: { - }, - rag_llm_model_id: { - }, - question_consolidator_llm_id: { - }, - allow_chat_channel_mentions: { - }, - allow_chat_direct_messages: { - }, - allow_topic_mentions: { - }, - allow_personal_messages: { - }, - tool_details: { - type: :large_text, - }, - forced_tool_count: { - }, - force_default_llm: { - }, - # JSON fields - json_fields: %i[tools response_format examples allowed_group_ids], - } - end - - def log_ai_persona_creation(ai_persona) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - entity_details = { persona_id: ai_persona.id, subject: ai_persona.name } - entity_details[:tools_count] = (ai_persona.tools || []).size - - logger.log_creation("persona", ai_persona, ai_persona_logger_fields, entity_details) - end - - def log_ai_persona_update(ai_persona, initial_attributes) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - entity_details = { persona_id: ai_persona.id, subject: ai_persona.name } - entity_details[:tools_count] = ai_persona.tools.size if ai_persona.tools.present? - - logger.log_update( - "persona", - ai_persona, - initial_attributes, - ai_persona_logger_fields, - entity_details, - ) - end - - def log_ai_persona_deletion(persona_details) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - persona_details[:subject] = persona_details[:name] - - logger.log_deletion("persona", persona_details) - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/ai_spam_controller.rb b/app/controllers/discourse_ai/admin/ai_spam_controller.rb deleted file mode 100644 index 19490391..00000000 --- a/app/controllers/discourse_ai/admin/ai_spam_controller.rb +++ /dev/null @@ -1,202 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class AiSpamController < ::Admin::AdminController - requires_plugin "discourse-ai" - - def show - render json: AiSpamSerializer.new(spam_config, root: false) - end - - def update - initial_settings = AiModerationSetting.spam - - initial_data = { - custom_instructions: initial_settings&.data&.dig("custom_instructions"), - llm_model_id: initial_settings&.llm_model_id, - ai_persona_id: initial_settings&.ai_persona_id, - } - - initial_custom_instructions = initial_settings&.data&.dig("custom_instructions") - initial_llm_model_id = initial_settings&.llm_model_id - - updated_params = {} - if allowed_params.key?(:llm_model_id) - llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id] - if llm_model_id.to_i < 0 && - !SiteSetting.ai_spam_detection_model_allowed_seeded_models_map.include?( - llm_model_id.to_s, - ) - return( - render_json_error( - I18n.t("discourse_ai.llm.configuration.invalid_seeded_model"), - status: 422, - ) - ) - end - end - - if allowed_params.key?(:ai_persona_id) - updated_params[:ai_persona_id] = allowed_params[:ai_persona_id] - persona = AiPersona.find_by(id: allowed_params[:ai_persona_id]) - if persona.nil? || - persona.response_format.to_a.none? { |rf| - rf["key"] == "spam" && rf["type"] == "boolean" - } - return( - render_json_error( - I18n.t("discourse_ai.llm.configuration.invalid_persona_response_format"), - status: 422, - ) - ) - end - end - updated_params[:data] = { - custom_instructions: allowed_params[:custom_instructions], - } if allowed_params.key?(:custom_instructions) - - if updated_params.present? - # not using upsert cause we will not get the correct validation errors - if AiModerationSetting.spam - AiModerationSetting.spam.update!(updated_params) - else - AiModerationSetting.create!(updated_params.merge(setting_type: :spam)) - end - - log_ai_spam_update(initial_data, allowed_params) - end - - is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled]) - - if allowed_params.key?(:is_enabled) - if is_enabled && !AiModerationSetting.spam&.llm_model_id - return( - render_json_error( - I18n.t("discourse_ai.llm.configuration.must_select_model"), - status: 422, - ) - ) - end - - SiteSetting.set_and_log("ai_spam_detection_enabled", is_enabled, current_user) - end - - render json: AiSpamSerializer.new(spam_config, root: false) - end - - def test - url = params[:post_url].to_s - post = nil - - if url.match?(/^\d+$/) - post_id = url.to_i - post = Post.find_by(id: post_id) - end - - route = UrlHelper.rails_route_from_url(url) if !post - - if route - if route[:controller] == "topics" - post_number = route[:post_number] || 1 - post = Post.with_deleted.find_by(post_number: post_number, topic_id: route[:topic_id]) - end - end - - raise Discourse::NotFound if !post - - result = - DiscourseAi::AiModeration::SpamScanner.test_post( - post, - custom_instructions: params[:custom_instructions], - llm_id: params[:llm_id], - ) - - render json: result - end - - def fix_errors - case params[:error] - when "spam_scanner_not_admin" - begin - DiscourseAi::AiModeration::SpamScanner.fix_spam_scanner_not_admin - render json: success_json - rescue ActiveRecord::RecordInvalid - render_json_error( - I18n.t("discourse_ai.spam_detection.bot_user_update_failed"), - status: :unprocessable_entity, - ) - rescue StandardError - render_json_error( - I18n.t("discourse_ai.spam_detection.unexpected"), - status: :internal_server_error, - ) - end - else - render_json_error( - I18n.t("discourse_ai.spam_detection.invalid_error_type"), - status: :bad_request, - ) - end - end - - private - - def log_ai_spam_update(initial_data, params) - changes_to_log = {} - - initial_llm_model_id = initial_data[:llm_model_id] - if params.key?(:llm_model_id) && initial_llm_model_id.to_s != params[:llm_model_id].to_s - old_model_name = - LlmModel.find_by(id: initial_llm_model_id)&.display_name || initial_llm_model_id - new_model_name = - LlmModel.find_by(id: params[:llm_model_id])&.display_name || params[:llm_model_id] - - changes_to_log[:llm_model_id] = "#{old_model_name} → #{new_model_name}" - end - - initial_custom_instructions = initial_data[:custom_instructions] - if params.key?(:custom_instructions) && - initial_custom_instructions != params[:custom_instructions] - changes_to_log[:custom_instructions] = params[:custom_instructions] - end - - initial_ai_persona_id = initial_data[:ai_persona_id] - if params.key?(:ai_persona_id) && initial_ai_persona_id.to_s != params[:ai_persona_id].to_s - old_persona_name = - AiPersona.find_by(id: initial_ai_persona_id)&.name || initial_ai_persona_id - new_persona_name = - AiPersona.find_by(id: params[:ai_persona_id])&.name || params[:ai_persona_id] - - changes_to_log[:ai_persona_id] = "#{old_persona_name} → #{new_persona_name}" - end - - if changes_to_log.present? - changes_to_log[:subject] = I18n.t("discourse_ai.spam_detection.logging_subject") - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - logger.log_custom("update_ai_spam_settings", changes_to_log) - end - end - - def allowed_params - params.permit(:is_enabled, :llm_model_id, :custom_instructions, :ai_persona_id) - end - - def spam_config - spam_config = { - enabled: SiteSetting.ai_spam_detection_enabled, - settings: AiModerationSetting.spam, - } - - spam_config[:stats] = DiscourseAi::AiModeration::SpamReport.generate(min_date: 1.week.ago) - - if spam_config[:stats].scanned_count > 0 - spam_config[ - :flagging_username - ] = DiscourseAi::AiModeration::SpamScanner.flagging_user&.username - end - spam_config - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/ai_tools_controller.rb b/app/controllers/discourse_ai/admin/ai_tools_controller.rb deleted file mode 100644 index e14ee011..00000000 --- a/app/controllers/discourse_ai/admin/ai_tools_controller.rb +++ /dev/null @@ -1,206 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class AiToolsController < ::Admin::AdminController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - before_action :find_ai_tool, only: %i[test edit update destroy export] - - def index - ai_tools = AiTool.all - render_serialized({ ai_tools: ai_tools }, AiCustomToolListSerializer, root: false) - end - - def new - end - - def edit - render_serialized(@ai_tool, AiCustomToolSerializer) - end - - def create - ai_tool = AiTool.new(ai_tool_params) - ai_tool.created_by_id = current_user.id - - if ai_tool.save - RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids) - log_ai_tool_creation(ai_tool) - render_serialized(ai_tool, AiCustomToolSerializer, status: :created) - else - render_json_error ai_tool - end - end - - def export - response.headers[ - "Content-Disposition" - ] = "attachment; filename=\"#{@ai_tool.tool_name}.json\"" - render_serialized(@ai_tool, AiCustomToolSerializer) - end - - def import - existing_tool = AiTool.find_by(tool_name: ai_tool_params[:tool_name]) - force_update = params[:force].present? && params[:force].to_s.downcase == "true" - - if existing_tool && !force_update - return( - render_json_error "Tool with tool_name '#{ai_tool_params[:tool_name]}' already exists. Use force=true to overwrite.", - status: :conflict - ) - end - - if existing_tool && force_update - initial_attributes = existing_tool.attributes.dup - if existing_tool.update(ai_tool_params) - log_ai_tool_update(existing_tool, initial_attributes) - render_serialized(existing_tool, AiCustomToolSerializer) - else - render_json_error existing_tool - end - else - ai_tool = AiTool.new(ai_tool_params) - ai_tool.created_by_id = current_user.id - - if ai_tool.save - log_ai_tool_creation(ai_tool) - render_serialized(ai_tool, AiCustomToolSerializer, status: :created) - else - render_json_error ai_tool - end - end - end - - def update - initial_attributes = @ai_tool.attributes.dup - - if @ai_tool.update(ai_tool_params) - RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids) - log_ai_tool_update(@ai_tool, initial_attributes) - render_serialized(@ai_tool, AiCustomToolSerializer) - else - render_json_error @ai_tool - end - end - - def destroy - tool_logger_details = { - tool_id: @ai_tool.id, - name: @ai_tool.name, - tool_name: @ai_tool.tool_name, - subject: @ai_tool.name, - } - - if @ai_tool.destroy - log_ai_tool_deletion(tool_logger_details) - head :no_content - else - render_json_error @ai_tool - end - end - - def test - @ai_tool.assign_attributes(ai_tool_params) if params[:ai_tool] - parameters = params[:parameters].to_unsafe_h - - # we need an llm so we have a tokenizer - # but will do without if none is available - llm = LlmModel.first&.to_llm - runner = @ai_tool.runner(parameters, llm: llm, bot_user: current_user) - result = runner.invoke - - if result.is_a?(Hash) && result[:error] - render_json_error result[:error] - else - render json: { output: result } - end - rescue ActiveRecord::RecordNotFound => e - render_json_error e.message, status: 400 - rescue => e - render_json_error "Error executing the tool: #{e.message}", status: 400 - end - - private - - def attached_upload_ids - params[:ai_tool][:rag_uploads].to_a.map { |h| h[:id] } - end - - def find_ai_tool - @ai_tool = AiTool.find(params[:id].to_i) - end - - def ai_tool_params - params - .require(:ai_tool) - .permit( - :name, - :tool_name, - :description, - :script, - :summary, - :rag_chunk_tokens, - :rag_chunk_overlap_tokens, - :rag_llm_model_id, - rag_uploads: [:id], - parameters: [:name, :type, :description, :required, enum: []], - ) - .except(:rag_uploads) - end - - def ai_tool_logger_fields - { - name: { - }, - tool_name: { - }, - description: { - }, - summary: { - }, - enabled: { - }, - rag_chunk_tokens: { - }, - rag_chunk_overlap_tokens: { - }, - rag_llm_model_id: { - }, - script: { - type: :large_text, - }, - parameters: { - type: :large_text, - }, - } - end - - def log_ai_tool_creation(ai_tool) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - - entity_details = { tool_id: ai_tool.id, subject: ai_tool.name } - entity_details[:parameter_count] = ai_tool.parameters.size if ai_tool.parameters.present? - - logger.log_creation("tool", ai_tool, ai_tool_logger_fields, entity_details) - end - - def log_ai_tool_update(ai_tool, initial_attributes) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - entity_details = { tool_id: ai_tool.id, subject: ai_tool.name } - - logger.log_update( - "tool", - ai_tool, - initial_attributes, - ai_tool_logger_fields, - entity_details, - ) - end - - def log_ai_tool_deletion(tool_details) - logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) - logger.log_deletion("tool", tool_details) - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/ai_usage_controller.rb b/app/controllers/discourse_ai/admin/ai_usage_controller.rb deleted file mode 100644 index ea57cd4a..00000000 --- a/app/controllers/discourse_ai/admin/ai_usage_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class AiUsageController < ::Admin::AdminController - requires_plugin "discourse-ai" - - def show - end - - def report - render json: AiUsageSerializer.new(create_report, root: false) - end - - private - - def create_report - report = - DiscourseAi::Completions::Report.new( - start_date: params[:start_date]&.to_date || 30.days.ago, - end_date: params[:end_date]&.to_date || Time.current, - ) - - report = report.filter_by_feature(params[:feature]) if params[:feature].present? - report = report.filter_by_model(params[:model]) if params[:model].present? - report - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/dashboard_controller.rb b/app/controllers/discourse_ai/admin/dashboard_controller.rb deleted file mode 100644 index 9fed8f08..00000000 --- a/app/controllers/discourse_ai/admin/dashboard_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class DashboardController < ::Admin::StaffController - requires_plugin DiscourseAi::PLUGIN_NAME - - def sentiment - end - end - end -end diff --git a/app/controllers/discourse_ai/admin/rag_document_fragments_controller.rb b/app/controllers/discourse_ai/admin/rag_document_fragments_controller.rb deleted file mode 100644 index d6ee53b4..00000000 --- a/app/controllers/discourse_ai/admin/rag_document_fragments_controller.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Admin - class RagDocumentFragmentsController < ::Admin::AdminController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - def indexing_status_check - if params[:target_type] == "AiPersona" - @target = AiPersona.find(params[:target_id]) - elsif params[:target_type] == "AiTool" - @target = AiTool.find(params[:target_id]) - else - raise Discourse::InvalidParameters.new("Invalid target type") - end - - render json: RagDocumentFragment.indexing_status(@target, @target.uploads) - end - - def upload_file - file = params[:file] || params[:files].first - - if !DiscourseAi::Embeddings.enabled? - raise Discourse::InvalidAccess.new("Embeddings not enabled") - end - - validate_extension!(file.original_filename) - validate_file_size!(file.tempfile.size) - - hijack do - upload = - UploadCreator.new( - file.tempfile, - file.original_filename, - type: "discourse_ai_rag_upload", - skip_validations: true, - ).create_for(current_user.id) - - if upload.persisted? - render json: UploadSerializer.new(upload) - else - render json: failed_json.merge(errors: upload.errors.full_messages), status: 422 - end - end - end - - private - - def validate_extension!(filename) - extension = File.extname(filename)[1..-1] || "" - authorized_extensions = %w[txt md pdf] - authorized_extensions.concat(%w[png jpg jpeg]) if SiteSetting.ai_rag_images_enabled - if !authorized_extensions.include?(extension) - raise Discourse::InvalidParameters.new( - I18n.t( - "upload.unauthorized", - authorized_extensions: authorized_extensions.join(" "), - ), - ) - end - end - - def validate_file_size!(filesize) - max_size_bytes = 20.megabytes - if filesize > max_size_bytes - raise Discourse::InvalidParameters.new( - I18n.t( - "upload.attachments.too_large_humanized", - max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size_bytes), - ), - ) - end - end - end - end -end diff --git a/app/controllers/discourse_ai/ai_bot/artifact_key_values_controller.rb b/app/controllers/discourse_ai/ai_bot/artifact_key_values_controller.rb deleted file mode 100644 index 83b003f7..00000000 --- a/app/controllers/discourse_ai/ai_bot/artifact_key_values_controller.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class ArtifactKeyValuesController < ::ApplicationController - requires_plugin DiscourseAi::PLUGIN_NAME - before_action :ensure_logged_in, only: %i[set destroy] - before_action :find_artifact - - PER_PAGE_MAX = 100 - - def index - page = index_params[:page].to_i - page = 1 if page < 1 - per_page = index_params[:per_page].to_i - per_page = PER_PAGE_MAX if per_page < 1 || per_page > PER_PAGE_MAX - - query = build_index_query - - total_count = query.count - key_values = - query - .includes(:user) - .order(:user_id, :key, :created_at) - .offset((page - 1) * per_page) - .limit(per_page + 1) - - has_more = key_values.length > per_page - key_values = key_values.first(per_page) if has_more - - render json: { - key_values: - ActiveModel::ArraySerializer.new( - key_values, - each_serializer: AiArtifactKeyValueSerializer, - keys_only: params[:keys_only] == "true", - ).as_json, - has_more: has_more, - total_count: total_count, - users: - key_values - .map { |kv| kv.user } - .uniq - .map { |u| BasicUserSerializer.new(u, root: nil).as_json }, - } - end - - def destroy - if params[:key].blank? - render json: { error: "Key parameter is required" }, status: :bad_request - return - end - - key_value = @artifact.key_values.find_by(user_id: current_user.id, key: params[:key]) - - if key_value.nil? - render json: { error: "Key not found" }, status: :not_found - elsif key_value.destroy - head :ok - else - render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity - end - end - - def set - key_value = - @artifact.key_values.find_or_initialize_by( - user: current_user, - key: key_value_params[:key], - ) - - key_value.assign_attributes(key_value_params.except(:key)) - - if key_value.save - render json: AiArtifactKeyValueSerializer.new(key_value).as_json - else - render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity - end - end - - private - - def key_value_params - params.permit(:key, :value, :public) - end - - def index_params - @index_params ||= params.permit(:page, :per_page, :key, :keys_only, :all_users) - end - - def build_index_query - query = @artifact.key_values - - query = - if current_user&.admin? - query - elsif current_user - query.where("user_id = ? OR public = true", current_user.id) - else - query.where(public: true) - end - - query = query.where("key = ?", index_params[:key]) if index_params[:key].present? - - if index_params[:all_users].to_s != "true" - if current_user - query = query.where(user_id: current_user.id) - else - query = query.where("1 = 0") - end - end - - query - end - - def find_artifact - @artifact = AiArtifact.find_by(id: params[:artifact_id]) - raise Discourse::NotFound if !@artifact - raise Discourse::NotFound if !@artifact.public? && guardian.anonymous? - raise Discourse::NotFound if !@artifact.public? && !guardian.can_see?(@artifact.post) - end - end - end -end diff --git a/app/controllers/discourse_ai/ai_bot/artifacts_controller.rb b/app/controllers/discourse_ai/ai_bot/artifacts_controller.rb deleted file mode 100644 index 9ce89cef..00000000 --- a/app/controllers/discourse_ai/ai_bot/artifacts_controller.rb +++ /dev/null @@ -1,344 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class ArtifactsController < ApplicationController - requires_plugin DiscourseAi::PLUGIN_NAME - before_action :require_site_settings! - - skip_before_action :preload_json, :check_xhr, only: %i[show] - - def show - artifact = AiArtifact.find(params[:id]) - - post = Post.find_by(id: artifact.post_id) - if artifact.public? - # no guardian needed - else - raise Discourse::NotFound if !post&.topic&.private_message? - raise Discourse::NotFound if !guardian.can_see?(post) - end - - name = artifact.name - artifact_version = nil - - if params[:version].present? - artifact_version = artifact.versions.find_by(version_number: params[:version]) - raise Discourse::NotFound if !artifact_version - end - - untrusted_html = build_untrusted_html(artifact_version || artifact, name) - trusted_html = build_trusted_html(artifact, artifact_version, name, untrusted_html) - - set_security_headers - render html: trusted_html.html_safe, layout: false, content_type: "text/html" - end - - private - - def build_untrusted_html(artifact, name) - js = prepare_javascript(artifact.js) - - <<~HTML - - - - - #{ERB::Util.html_escape(name)} - - #{build_iframe_javascript} - - - #{artifact.html} - #{js} - - - HTML - end - - def build_trusted_html(artifact, artifact_version, name, untrusted_html) - <<~HTML - - - - - #{ERB::Util.html_escape(name)} - - - - - - - #{build_parent_javascript(artifact)} - - - HTML - end - - def prepare_javascript(js) - return "" if js.blank? - - if !js.match?(%r{\A\s*}mi) - mod = "" - mod = " type=\"module\"" if js.match?(/\A\s*import.*/) - js = "\n#{js}\n" - end - js - end - - def user_data - { - username: current_user ? current_user.username : nil, - user_id: current_user ? current_user.id : nil, - name: current_user ? current_user.name : nil, - } - end - - def build_iframe_javascript - <<~JAVASCRIPT - - JAVASCRIPT - end - - def build_parent_javascript(artifact) - <<~JAVASCRIPT - - JAVASCRIPT - end - - def set_security_headers - response.headers.delete("X-Frame-Options") - response.headers[ - "Content-Security-Policy" - ] = "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' #{AiArtifact::ALLOWED_CDN_SOURCES.join(" ")};" - response.headers["X-Robots-Tag"] = "noindex" - end - - def require_site_settings! - if !SiteSetting.discourse_ai_enabled || - !SiteSetting.ai_artifact_security.in?(%w[lax hybrid strict]) - raise Discourse::NotFound - end - end - end - end -end diff --git a/app/controllers/discourse_ai/ai_bot/bot_controller.rb b/app/controllers/discourse_ai/ai_bot/bot_controller.rb deleted file mode 100644 index bafcec36..00000000 --- a/app/controllers/discourse_ai/ai_bot/bot_controller.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class BotController < ::ApplicationController - requires_plugin ::DiscourseAi::PLUGIN_NAME - requires_login - - def show_debug_info_by_id - log = AiApiAuditLog.find(params[:id]) - raise Discourse::NotFound if !log.topic - - guardian.ensure_can_debug_ai_bot_conversation!(log.topic) - render json: AiApiAuditLogSerializer.new(log, root: false), status: 200 - end - - def show_debug_info - post = Post.find(params[:post_id]) - guardian.ensure_can_debug_ai_bot_conversation!(post) - - posts = - Post - .where("post_number <= ?", post.post_number) - .where(topic_id: post.topic_id) - .order("post_number DESC") - - debug_info = AiApiAuditLog.where(post: posts).order(created_at: :desc).first - - render json: AiApiAuditLogSerializer.new(debug_info, root: false), status: 200 - end - - def stop_streaming_response - post = Post.find(params[:post_id]) - guardian.ensure_can_see!(post) - - Discourse.redis.del("gpt_cancel:#{post.id}") - - render json: {}, status: 200 - end - - def show_bot_username - bot_user = DiscourseAi::AiBot::EntryPoint.find_user_from_model(params[:username]) - raise Discourse::InvalidParameters.new(:username) if !bot_user - - render json: { bot_username: bot_user.username_lower }, status: 200 - end - - def discover - ai_persona = - AiPersona - .all_personas(enabled_only: false) - .find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i } - - if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a) - raise Discourse::InvalidAccess.new - end - - if ai_persona.default_llm_id.blank? - render_json_error "Discover persona is missing a default LLM model.", status: 503 - return - end - - query = params[:query] - raise Discourse::InvalidParameters.new("Missing query to discover") if query.blank? - - RateLimiter.new(current_user, "ai_bot_discover_#{current_user.id}", 3, 1.minute).performed! - - Jobs.enqueue(:stream_discover_reply, user_id: current_user.id, query: query) - - render json: {}, status: 200 - end - - def discover_continue_convo - raise Discourse::InvalidParameters.new("user_id") if !params[:user_id] - raise Discourse::InvalidParameters.new("query") if !params[:query] - raise Discourse::InvalidParameters.new("context") if !params[:context] - - user = User.find(params[:user_id]) - - bot_user_id = AiPersona.find_by(id: SiteSetting.ai_bot_discover_persona).user_id - bot_username = User.find_by(id: bot_user_id).username - - query = params[:query] - context = "[quote]\n#{params[:context]}\n[/quote]" - - post = - PostCreator.create!( - user, - title: - I18n.t("discourse_ai.ai_bot.discoveries.continue_conversation.title", query: query), - raw: - I18n.t( - "discourse_ai.ai_bot.discoveries.continue_conversation.raw", - query: query, - context: context, - ), - archetype: Archetype.private_message, - target_usernames: bot_username, - skip_validations: true, - ) - - render json: success_json.merge(topic_id: post.topic_id) - rescue StandardError => e - render json: failed_json.merge(errors: [e.message]), status: 422 - end - end - end -end diff --git a/app/controllers/discourse_ai/ai_bot/conversations_controller.rb b/app/controllers/discourse_ai/ai_bot/conversations_controller.rb deleted file mode 100644 index 83863baf..00000000 --- a/app/controllers/discourse_ai/ai_bot/conversations_controller.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class ConversationsController < ::ApplicationController - requires_plugin ::DiscourseAi::PLUGIN_NAME - requires_login - - def index - page = params[:page].to_i - per_page = params[:per_page]&.to_i || 40 - - base_query = - Topic - .private_messages_for_user(current_user) - .where(user: current_user) # Only show PMs where the current user is the author - .joins( - "INNER JOIN topic_custom_fields tcf ON tcf.topic_id = topics.id - AND tcf.name = '#{DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD}' - AND tcf.value = 't'", - ) - .distinct - - total = base_query.count - pms = base_query.order(last_posted_at: :desc).offset(page * per_page).limit(per_page) - - render json: { - conversations: serialize_data(pms, ListableTopicSerializer), - meta: { - total: total, - page: page, - per_page: per_page, - has_more: total > (page + 1) * per_page, - }, - } - end - end - end -end diff --git a/app/controllers/discourse_ai/ai_bot/shared_ai_conversations_controller.rb b/app/controllers/discourse_ai/ai_bot/shared_ai_conversations_controller.rb deleted file mode 100644 index 3d86b2d4..00000000 --- a/app/controllers/discourse_ai/ai_bot/shared_ai_conversations_controller.rb +++ /dev/null @@ -1,136 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class SharedAiConversationsController < ::ApplicationController - requires_plugin ::DiscourseAi::PLUGIN_NAME - requires_login only: %i[create update destroy] - before_action :require_site_settings! - - skip_before_action :preload_json, :check_xhr, only: %i[show asset] - skip_before_action :redirect_to_login_if_required, :verify_authenticity_token, only: %i[asset] - - def create - ensure_allowed_create! - - RateLimiter.new(current_user, "share-ai-conversation", 10, 1.minute).performed! - - shared_conversation = SharedAiConversation.share_conversation(current_user, @topic) - - if shared_conversation.persisted? - render json: success_json.merge(share_key: shared_conversation.share_key) - else - render json: - failed_json.merge(error: I18n.t("discourse_ai.share_ai.errors.failed_to_share")), - status: :unprocessable_entity - end - end - - def destroy - ensure_allowed_destroy! - - SharedAiConversation.destroy_conversation(@shared_conversation) - - render json: - success_json.merge( - message: I18n.t("discourse_ai.share_ai.errors.conversation_deleted"), - ) - end - - def show - @shared_conversation = SharedAiConversation.find_by(share_key: params[:share_key]) - raise Discourse::NotFound if @shared_conversation.blank? - - expires_in 1.minute, public: true - response.headers["X-Robots-Tag"] = "noindex" - - if request.format.json? - render json: success_json.merge(@shared_conversation.to_json) - else - render "show", layout: false - end - end - - def asset - no_cookies - - name = params[:name] - path, content_type = - if name == "share" - %w[share.css text/css] - elsif name == "highlight" - %w[highlight.min.js application/javascript] - else - raise Discourse::NotFound - end - - content = File.read(DiscourseAi.public_asset_path("ai-share/#{path}")) - - # note, path contains a ":version" which automatically busts the cache - # based on file content, so this is safe - response.headers["Last-Modified"] = 10.years.ago.httpdate - response.headers["Content-Length"] = content.bytesize.to_s - immutable_for 1.year - - render plain: content, disposition: :nil, content_type: content_type - end - - def preview - ensure_allowed_preview! - data = SharedAiConversation.build_conversation_data(@topic, include_usernames: true) - data[:error] = @error if @error - data[:share_key] = @shared_conversation.share_key if @shared_conversation - data[:topic_id] = @topic.id - render json: data - end - - private - - def require_site_settings! - if !SiteSetting.discourse_ai_enabled || - !SiteSetting.ai_bot_public_sharing_allowed_groups_map.any? || - !SiteSetting.ai_bot_enabled - raise Discourse::NotFound - end - end - - def ensure_allowed_preview! - @topic = Topic.find_by(id: params[:topic_id]) - raise Discourse::NotFound if !@topic - - @shared_conversation = SharedAiConversation.find_by(target: @topic) - - @error = DiscourseAi::AiBot::EntryPoint.ai_share_error(@topic, guardian) - if @error == :not_allowed - raise Discourse::InvalidAccess.new( - nil, - nil, - custom_message: "discourse_ai.share_ai.errors.#{@error}", - ) - end - end - - def ensure_allowed_destroy! - @shared_conversation = SharedAiConversation.find_by(share_key: params[:share_key]) - - raise Discourse::InvalidAccess if @shared_conversation.blank? - - guardian.ensure_can_destroy_shared_ai_bot_conversation!(@shared_conversation) - end - - def ensure_allowed_create! - @topic = Topic.find_by(id: params[:topic_id]) - raise Discourse::NotFound if !@topic - - error = DiscourseAi::AiBot::EntryPoint.ai_share_error(@topic, guardian) - if error - raise Discourse::InvalidAccess.new( - nil, - nil, - custom_message: "discourse_ai.share_ai.errors.#{error}", - ) - end - end - end - end -end diff --git a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb deleted file mode 100644 index 8a65a187..00000000 --- a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb +++ /dev/null @@ -1,241 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiHelper - class AssistantController < ::ApplicationController - requires_plugin ::DiscourseAi::PLUGIN_NAME - requires_login - before_action :ensure_can_request_suggestions - before_action :rate_limiter_performed! - - include SecureUploadEndpointHelpers - - RATE_LIMITS = { - "default" => { - amount: 6, - interval: 3.minutes, - }, - "caption_image" => { - amount: 20, - interval: 1.minute, - }, - }.freeze - - def suggest - input = get_text_param! - force_default_locale = params[:force_default_locale] || false - - raise Discourse::InvalidParameters.new(:mode) if params[:mode].blank? - - if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT - raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank? - end - - if params[:mode] == DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST - return suggest_thumbnails(input) - end - - hijack do - render json: - DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt( - params[:mode], - input, - current_user, - force_default_locale: force_default_locale, - custom_prompt: params[:custom_prompt], - ), - status: 200 - end - rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed - render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"), - status: 502 - end - - def suggest_title - if params[:topic_id] - topic = Topic.find_by(id: params[:topic_id]) - input = DiscourseAi::Summarization::Strategies::TopicSummary.new(topic).targets_data - else - input = get_text_param! - end - - hijack do - render json: - DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt( - DiscourseAi::AiHelper::Assistant::GENERATE_TITLES, - input, - current_user, - ), - status: 200 - end - rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed - render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"), - status: 502 - end - - def suggest_category - if params[:topic_id] - opts = { topic_id: params[:topic_id] } - else - input = get_text_param! - opts = { text: input } - end - - render json: DiscourseAi::AiHelper::SemanticCategorizer.new(current_user, opts).categories, - status: 200 - end - - def suggest_tags - if params[:topic_id] - opts = { topic_id: params[:topic_id] } - else - input = get_text_param! - opts = { text: input } - end - - render json: DiscourseAi::AiHelper::SemanticCategorizer.new(current_user, opts).tags, - status: 200 - end - - def suggest_thumbnails(input) - hijack do - thumbnails = DiscourseAi::AiHelper::Painter.new.commission_thumbnails(input, current_user) - - render json: { thumbnails: thumbnails }, status: 200 - end - end - - def stream_suggestion - text = get_text_param! - - location = params[:location] - raise Discourse::InvalidParameters.new(:location) if !location - - raise Discourse::InvalidParameters.new(:mode) if params[:mode].blank? - if params[:mode] == DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST - return suggest_thumbnails(input) - end - - if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT - raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank? - end - - # to stream we must have an appropriate client_id - # otherwise we may end up streaming the data to the wrong client - raise Discourse::InvalidParameters.new(:client_id) if params[:client_id].blank? - - channel_id = next_channel_id - progress_channel = "discourse_ai_helper/stream_suggestions/#{channel_id}" - - if location == "composer" - Jobs.enqueue( - :stream_composer_helper, - user_id: current_user.id, - text: text, - prompt: params[:mode], - custom_prompt: params[:custom_prompt], - force_default_locale: params[:force_default_locale] || false, - client_id: params[:client_id], - progress_channel:, - ) - else - post_id = get_post_param! - post = Post.includes(:topic).find_by(id: post_id) - - raise Discourse::InvalidParameters.new(:post_id) unless post - - Jobs.enqueue( - :stream_post_helper, - post_id: post.id, - user_id: current_user.id, - text: text, - prompt: params[:mode], - custom_prompt: params[:custom_prompt], - client_id: params[:client_id], - progress_channel:, - ) - end - - render json: { success: true, progress_channel: }, status: 200 - rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed - render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"), - status: 502 - end - - def caption_image - image_url = params[:image_url] - image_url_type = params[:image_url_type] - - raise Discourse::InvalidParameters.new(:image_url) if !image_url - raise Discourse::InvalidParameters.new(:image_url) if !image_url_type - - if image_url_type == "short_path" - image = Upload.find_by(sha1: Upload.sha1_from_short_path(image_url)) - elsif image_url_type == "short_url" - image = Upload.find_by(sha1: Upload.sha1_from_short_url(image_url)) - else - image = upload_from_full_url(image_url) - end - - raise Discourse::NotFound if image.blank? - - check_secure_upload_permission(image) if image.secure? - user = current_user - - hijack do - caption = DiscourseAi::AiHelper::Assistant.new.generate_image_caption(image, user) - render json: { - caption: - "#{caption} (#{I18n.t("discourse_ai.ai_helper.image_caption.attribution")})", - }, - status: 200 - end - rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed, Net::HTTPBadResponse - render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"), - status: 502 - end - - private - - CHANNEL_ID_KEY = "discourse_ai_helper_next_channel_id" - - def next_channel_id - Discourse - .redis - .pipelined do |pipeline| - pipeline.incr(CHANNEL_ID_KEY) - pipeline.expire(CHANNEL_ID_KEY, 1.day) - end - .first - end - - def get_text_param! - params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? } - end - - def get_post_param! - params[:post_id].tap { |t| raise Discourse::InvalidParameters.new(:post_id) if t.blank? } - end - - def rate_limiter_performed! - action_rate_limit = RATE_LIMITS[action_name] || RATE_LIMITS["default"] - RateLimiter.new( - current_user, - "ai_assistant", - action_rate_limit[:amount], - action_rate_limit[:interval], - ).performed! - end - - def ensure_can_request_suggestions - allowed_groups = - ( - SiteSetting.composer_ai_helper_allowed_groups_map | - SiteSetting.post_ai_helper_allowed_groups_map - ) - - raise Discourse::InvalidAccess if !current_user.in_any_groups?(allowed_groups) - end - end - end -end diff --git a/app/controllers/discourse_ai/discord/bot_controller.rb b/app/controllers/discourse_ai/discord/bot_controller.rb deleted file mode 100644 index d553aa27..00000000 --- a/app/controllers/discourse_ai/discord/bot_controller.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Discord - class BotController < ::ApplicationController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - skip_before_action :verify_authenticity_token - - def interactions - # Request signature verification - begin - verify_request! - rescue Ed25519::VerifyError - return head :unauthorized - end - - body = request.body.read - interaction = JSON.parse(body, object_class: OpenStruct) - - if interaction.type == 1 - # Respond to Discord PING request - render json: { type: 1 } - else - if !SiteSetting.ai_discord_allowed_guilds_map.include?(interaction.guild_id) - return head :forbidden - end - - response = { type: 5, data: { content: "Searching..." } } - hijack { render json: response } - - # Respond to Discord command - Jobs.enqueue(:stream_discord_reply, interaction: body) - end - end - - private - - def verify_request! - signature = request.headers["X-Signature-Ed25519"] - timestamp = request.headers["X-Signature-Timestamp"] - verify_key.verify([signature].pack("H*"), "#{timestamp}#{request.raw_post}") - end - - def verify_key - Ed25519::VerifyKey.new([SiteSetting.ai_discord_app_public_key].pack("H*")).freeze - end - end - end -end diff --git a/app/controllers/discourse_ai/embeddings/embeddings_controller.rb b/app/controllers/discourse_ai/embeddings/embeddings_controller.rb deleted file mode 100644 index 5dcca0c3..00000000 --- a/app/controllers/discourse_ai/embeddings/embeddings_controller.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Embeddings - class EmbeddingsController < ::ApplicationController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - SEMANTIC_SEARCH_TYPE = "semantic_search" - - MAX_HYDE_SEARCHES_PER_MINUTE = 4 - MAX_SEARCHES_PER_MINUTE = 100 - - def search - query = params[:q].to_s - skip_hyde = params[:hyde].to_s.downcase == "false" || params[:hyde].to_s == "0" - - if query.length < SiteSetting.min_search_term_length - raise Discourse::InvalidParameters.new(:q) - end - - grouped_results = - Search::GroupedSearchResults.new( - type_filter: SEMANTIC_SEARCH_TYPE, - term: query, - search_context: guardian, - use_pg_headlines_for_excerpt: false, - can_lazy_load_categories: guardian.can_lazy_load_categories?, - ) - - semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian) - - if !skip_hyde && !semantic_search.cached_query?(query) - RateLimiter.new( - current_user, - "semantic-search", - MAX_HYDE_SEARCHES_PER_MINUTE, - 1.minutes, - ).performed! - else - RateLimiter.new( - current_user, - "semantic-search-non-hyde", - MAX_SEARCHES_PER_MINUTE, - 1.minutes, - ).performed! - end - - hijack do - begin - semantic_search - .search_for_topics(query, _page = 1, hyde: !skip_hyde) - .each { |topic_post| grouped_results.add(topic_post) } - - render_serialized( - grouped_results, - GroupedSearchResultSerializer, - result: grouped_results, - ) - rescue Discourse::InvalidAccess - render_json_error(I18n.t("invalid_access"), status: 403) - end - end - end - - def quick_search - # this search function searches posts (vs: topics) - # it requires post embeddings and a reranker - # it will not perform a hyde expantion - query = params[:q].to_s - - if query.length < SiteSetting.min_search_term_length - raise Discourse::InvalidParameters.new(:q) - end - - grouped_results = - Search::GroupedSearchResults.new( - type_filter: SEMANTIC_SEARCH_TYPE, - term: query, - search_context: guardian, - use_pg_headlines_for_excerpt: false, - ) - - semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian) - - if !semantic_search.cached_query?(query) - RateLimiter.new(current_user, "semantic-search", 60, 1.minutes).performed! - end - - hijack do - semantic_search.quick_search(query).each { |topic_post| grouped_results.add(topic_post) } - - render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results) - end - end - end - end -end diff --git a/app/controllers/discourse_ai/sentiment/sentiment_controller.rb b/app/controllers/discourse_ai/sentiment/sentiment_controller.rb deleted file mode 100644 index 5dd28993..00000000 --- a/app/controllers/discourse_ai/sentiment/sentiment_controller.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class SentimentController < ::Admin::StaffController - include Constants - requires_plugin ::DiscourseAi::PLUGIN_NAME - - DEFAULT_POSTS_LIMIT = 50 - MAX_POSTS_LIMIT = 100 - - def posts - group_by = params.required(:group_by)&.to_sym - group_value = params.required(:group_value).presence - start_date = params[:start_date].presence - end_date = params[:end_date] - threshold = SENTIMENT_THRESHOLD - - raise Discourse::InvalidParameters if %i[category tag].exclude?(group_by) - - limit = fetch_limit_from_params(default: DEFAULT_POSTS_LIMIT, max: MAX_POSTS_LIMIT) - offset = params[:offset].to_i || 0 - - case group_by - when :category - grouping_clause = "c.name" - grouping_join = "" # categories already joined - when :tag - grouping_clause = "tags.name" - grouping_join = - "INNER JOIN topic_tags tt ON tt.topic_id = p.topic_id INNER JOIN tags ON tags.id = tt.tag_id" - end - - posts = - DB.query( - <<~SQL, - SELECT - p.id AS post_id, - p.topic_id, - t.fancy_title AS topic_title, - p.cooked as post_cooked, - p.user_id, - p.post_number, - p.created_at AS created_at, - u.username, - u.name, - u.uploaded_avatar_id, - c.id AS category_id, - (CASE - WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive' - WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative' - ELSE 'neutral' - END) AS sentiment - FROM posts p - INNER JOIN topics t ON t.id = p.topic_id - INNER JOIN classification_results cr ON cr.target_id = p.id AND cr.target_type = 'Post' - LEFT JOIN users u ON u.id = p.user_id - LEFT JOIN categories c ON c.id = t.category_id - #{grouping_join} - WHERE - #{grouping_clause} = :group_value AND - t.archetype = 'regular' AND - p.user_id > 0 AND - cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND - ((:start_date IS NULL OR p.created_at > :start_date) AND (:end_date IS NULL OR p.created_at < :end_date)) - AND p.deleted_at IS NULL - ORDER BY p.created_at DESC - LIMIT :limit OFFSET :offset - SQL - group_value: group_value, - start_date: start_date, - end_date: end_date, - threshold: threshold, - limit: limit + 1, - offset: offset, - ) - - has_more = posts.length > limit - posts.pop if has_more - - render_json_dump( - posts: - serialize_data( - posts, - AiSentimentPostSerializer, - scope: guardian, - add_raw: true, - add_excerpt: true, - add_title: true, - ), - has_more: has_more, - next_offset: has_more ? offset + limit : nil, - ) - end - end - end -end diff --git a/app/controllers/discourse_ai/summarization/chat_summary_controller.rb b/app/controllers/discourse_ai/summarization/chat_summary_controller.rb deleted file mode 100644 index c66b1f2c..00000000 --- a/app/controllers/discourse_ai/summarization/chat_summary_controller.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - class ChatSummaryController < ::Chat::ApiController - requires_plugin ::DiscourseAi::PLUGIN_NAME - requires_plugin ::Chat::PLUGIN_NAME - - VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168] - - def show - since = params[:since].to_i - raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since) - - channel = ::Chat::Channel.find(params[:channel_id]) - guardian.ensure_can_join_chat_channel!(channel) - - summarizer = DiscourseAi::Summarization.chat_channel_summary(channel, since) - raise Discourse::NotFound.new unless summarizer - - guardian.ensure_can_request_summary! - - RateLimiter.new(current_user, "channel_summary", 6, 5.minutes).performed! - - hijack do - strategy = DiscourseAi::Summarization::Strategies::ChatMessages.new(channel, since) - - summarized_text = - if strategy.targets_data.empty? - I18n.t("discourse_ai.summarization.chat.no_targets") - else - summarizer.summarize(current_user)&.summarized_text - end - - render json: { summary: summarized_text } - end - end - end - end -end diff --git a/app/controllers/discourse_ai/summarization/summary_controller.rb b/app/controllers/discourse_ai/summarization/summary_controller.rb deleted file mode 100644 index 67e7ec82..00000000 --- a/app/controllers/discourse_ai/summarization/summary_controller.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - class SummaryController < ::ApplicationController - requires_plugin ::DiscourseAi::PLUGIN_NAME - - def show - topic = Topic.find(params[:topic_id]) - guardian.ensure_can_see!(topic) - - raise Discourse::NotFound if !guardian.can_see_summary?(topic) - - RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user - - opts = params.permit(:skip_age_check) - skip_age_check = opts[:skip_age_check] == "true" - - summarization_service = DiscourseAi::TopicSummarization.for(topic, current_user) - - if params[:stream] && current_user - cached_summary = summarization_service.cached_summary - - if cached_summary && !skip_age_check - render_serialized(cached_summary, AiTopicSummarySerializer) - return - end - - Jobs.enqueue( - :stream_topic_ai_summary, - topic_id: topic.id, - user_id: current_user.id, - skip_age_check: skip_age_check, - ) - - render json: success_json - else - hijack do - summary = summarization_service.summarize(skip_age_check: skip_age_check) - render_serialized(summary, AiTopicSummarySerializer) - end - end - end - end - end -end diff --git a/app/helpers/discourse_ai/ai_bot/shared_ai_conversations_helper.rb b/app/helpers/discourse_ai/ai_bot/shared_ai_conversations_helper.rb deleted file mode 100644 index 6fb89d58..00000000 --- a/app/helpers/discourse_ai/ai_bot/shared_ai_conversations_helper.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module AiBot - module SharedAiConversationsHelper - # keeping it here for caching - def self.share_asset_url(asset_name) - if !%w[share.css highlight.js].include?(asset_name) - raise StandardError, "unknown asset type #{asset_name}" - end - - @urls ||= {} - url = @urls[asset_name] - return url if url - - path = asset_name - path = "highlight.min.js" if asset_name == "highlight.js" - - content = File.read(DiscourseAi.public_asset_path("ai-share/#{path}")) - sha1 = Digest::SHA1.hexdigest(content) - - url = "/discourse-ai/ai-bot/shared-ai-conversations/asset/#{sha1}/#{asset_name}" - - @urls[asset_name] = GlobalPath.cdn_path(url) - end - - def share_asset_url(asset_name) - DiscourseAi::AiBot::SharedAiConversationsHelper.share_asset_url(asset_name) - end - end - end -end diff --git a/app/jobs/regular/ai_spam_scan.rb b/app/jobs/regular/ai_spam_scan.rb deleted file mode 100644 index 483e56ec..00000000 --- a/app/jobs/regular/ai_spam_scan.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class AiSpamScan < ::Jobs::Base - def execute(args) - return if !args[:post_id] - post = Post.find_by(id: args[:post_id]) - return if !post - - DiscourseAi::AiModeration::SpamScanner.perform_scan(post) - end - end -end diff --git a/app/jobs/regular/create_ai_chat_reply.rb b/app/jobs/regular/create_ai_chat_reply.rb deleted file mode 100644 index 61f824b6..00000000 --- a/app/jobs/regular/create_ai_chat_reply.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class CreateAiChatReply < ::Jobs::Base - sidekiq_options retry: false - - def execute(args) - channel = ::Chat::Channel.find_by(id: args[:channel_id]) - return if channel.blank? - - message = ::Chat::Message.find_by(id: args[:message_id]) - return if message.blank? - - personaClass = - DiscourseAi::Personas::Persona.find_by(id: args[:persona_id], user: message.user) - return if personaClass.blank? - - user = User.find_by(id: personaClass.user_id) - bot = DiscourseAi::Personas::Bot.as(user, persona: personaClass.new) - - DiscourseAi::AiBot::Playground.new(bot).reply_to_chat_message( - message, - channel, - args[:context_post_ids], - ) - end - end -end diff --git a/app/jobs/regular/create_ai_reply.rb b/app/jobs/regular/create_ai_reply.rb deleted file mode 100644 index 14653a61..00000000 --- a/app/jobs/regular/create_ai_reply.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class CreateAiReply < ::Jobs::Base - sidekiq_options retry: false - - def execute(args) - return unless bot_user = User.find_by(id: args[:bot_user_id]) - return unless post = Post.includes(:topic).find_by(id: args[:post_id]) - persona_id = args[:persona_id] - - begin - persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id) - raise DiscourseAi::Personas::Bot::BOT_NOT_FOUND if persona.nil? - - bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new) - - DiscourseAi::AiBot::Playground.new(bot).reply_to(post, feature_name: "bot") - rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND - Rails.logger.warn( - "Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled", - ) - end - end - end -end diff --git a/app/jobs/regular/detect_translate_post.rb b/app/jobs/regular/detect_translate_post.rb deleted file mode 100644 index 84b93a0a..00000000 --- a/app/jobs/regular/detect_translate_post.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class DetectTranslatePost < ::Jobs::Base - cluster_concurrency 1 - sidekiq_options retry: false - - def execute(args) - return if !DiscourseAi::Translation.enabled? - return if args[:post_id].blank? - - post = Post.find_by(id: args[:post_id]) - return if post.blank? || post.raw.blank? || post.deleted_at.present? || post.user_id <= 0 - - topic = post.topic - return if topic.blank? - - if SiteSetting.ai_translation_backfill_limit_to_public_content - return if topic.category&.read_restricted? || topic.archetype == Archetype.private_message - else - if topic.archetype == Archetype.private_message && - !TopicAllowedGroup.exists?(topic_id: topic.id) - return - end - end - - # the user may fill locale in manually - if (detected_locale = post.locale).blank? - begin - detected_locale = DiscourseAi::Translation::PostLocaleDetector.detect_locale(post) - rescue FinalDestination::SSRFDetector::LookupFailedError - # this job is non-critical - # the backfill job will handle failures - return - end - end - - return if detected_locale.blank? - locales = SiteSetting.content_localization_supported_locales.split("|") - return if locales.blank? - - locales.each do |locale| - next if LocaleNormalizer.is_same?(locale, detected_locale) - regionless_locale = locale.split("_").first - next if post.post_localizations.where("locale LIKE ?", "#{regionless_locale}%").exists? - - begin - DiscourseAi::Translation::PostLocalizer.localize(post, locale) - rescue FinalDestination::SSRFDetector::LookupFailedError - # do nothing, there are too many sporadic lookup failures - rescue => e - DiscourseAi::Translation::VerboseLogger.log( - "Failed to translate post #{post.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}", - ) - end - end - - MessageBus.publish("/topic/#{post.topic_id}", type: :localized, id: post.id) - end - end -end diff --git a/app/jobs/regular/detect_translate_topic.rb b/app/jobs/regular/detect_translate_topic.rb deleted file mode 100644 index e5cc1543..00000000 --- a/app/jobs/regular/detect_translate_topic.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class DetectTranslateTopic < ::Jobs::Base - cluster_concurrency 1 - sidekiq_options retry: false - - def execute(args) - return if !DiscourseAi::Translation.enabled? - return if args[:topic_id].blank? - - topic = Topic.find_by(id: args[:topic_id]) - if topic.blank? || topic.title.blank? || topic.deleted_at.present? || topic.user_id <= 0 - return - end - - if SiteSetting.ai_translation_backfill_limit_to_public_content - return if topic.category&.read_restricted? || topic.archetype == Archetype.private_message - else - if topic.archetype == Archetype.private_message && - !TopicAllowedGroup.exists?(topic_id: topic.id) - return - end - end - - if (detected_locale = topic.locale).blank? - begin - detected_locale = DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic) - rescue FinalDestination::SSRFDetector::LookupFailedError - # this job is non-critical - # the backfill job will handle failures - return - end - end - - return if detected_locale.blank? - locales = SiteSetting.content_localization_supported_locales.split("|") - return if locales.blank? - - locales.each do |locale| - next if LocaleNormalizer.is_same?(locale, detected_locale) - regionless_locale = locale.split("_").first - next if topic.topic_localizations.where("locale LIKE ?", "#{regionless_locale}%").exists? - - begin - DiscourseAi::Translation::TopicLocalizer.localize(topic, locale) - rescue FinalDestination::SSRFDetector::LookupFailedError - # do nothing, there are too many sporadic lookup failures - rescue => e - DiscourseAi::Translation::VerboseLogger.log( - "Failed to translate topic #{topic.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}", - ) - end - end - - MessageBus.publish("/topic/#{topic.id}", type: :localized, id: 1) - end - end -end diff --git a/app/jobs/regular/digest_rag_upload.rb b/app/jobs/regular/digest_rag_upload.rb deleted file mode 100644 index 4ac45971..00000000 --- a/app/jobs/regular/digest_rag_upload.rb +++ /dev/null @@ -1,204 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class DigestRagUpload < ::Jobs::Base - CHUNK_SIZE = 1024 - CHUNK_OVERLAP = 64 - MAX_FRAGMENTS = 100_000 - - # TODO(roman): Add a way to automatically recover from errors, resulting in unindexed uploads. - def execute(args) - return if (upload = Upload.find_by(id: args[:upload_id])).nil? - - target_type = args[:target_type] - target_id = args[:target_id] - - return if !target_type || !target_id - - target = target_type.constantize.find_by(id: target_id) - return if !target - - vector_rep = DiscourseAi::Embeddings::Vector.instance - - tokenizer = vector_rep.tokenizer - chunk_tokens = target.rag_chunk_tokens - overlap_tokens = target.rag_chunk_overlap_tokens - - fragment_ids = RagDocumentFragment.where(target: target, upload: upload).pluck(:id) - - # Check if this is the first time we process this upload. - if fragment_ids.empty? - document = get_uploaded_file(upload: upload, target: target) - return if document.nil? - - RagDocumentFragment.publish_status(upload, { total: 0, indexed: 0, left: 0 }) - - fragment_ids = [] - idx = 0 - - ActiveRecord::Base.transaction do - chunk_document( - file: document, - tokenizer: tokenizer, - chunk_tokens: chunk_tokens, - overlap_tokens: overlap_tokens, - ) do |chunk, metadata| - fragment_ids << RagDocumentFragment.create!( - target: target, - fragment: chunk, - fragment_number: idx + 1, - upload: upload, - metadata: metadata, - ).id - - idx += 1 - - if idx > MAX_FRAGMENTS - Rails.logger.warn("Upload #{upload.id} has too many fragments, truncating.") - break - end - end - end - end - - fragment_ids.each_slice(50) do |slice| - Jobs.enqueue(:generate_rag_embeddings, fragment_ids: slice) - end - end - - private - - def chunk_document(file:, tokenizer:, chunk_tokens:, overlap_tokens:) - buffer = +"" - current_metadata = nil - done = false - overlap = "" - - # generally this will be plenty - read_size = chunk_tokens * 10 - - while buffer.present? || !done - if buffer.length < read_size - read = file.read(read_size) - done = true if read.nil? - - read = Encodings.to_utf8(read) if read - - buffer << (read || "") - end - - # at this point we unconditionally have 2x CHUNK_SIZE worth of data in the buffer - metadata_regex = /\[\[metadata (.*?)\]\]/m - - before_metadata, new_metadata, after_metadata = buffer.split(metadata_regex) - to_chunk = nil - - if before_metadata.present? - to_chunk = before_metadata - elsif after_metadata.present? - current_metadata = new_metadata - to_chunk = after_metadata - buffer = buffer.split(metadata_regex, 2).last - overlap = "" - else - current_metadata = new_metadata - buffer = buffer.split(metadata_regex, 2).last - overlap = "" - next - end - - chunk, split_char = first_chunk(to_chunk, tokenizer: tokenizer, chunk_tokens: chunk_tokens) - buffer = buffer[chunk.length..-1] - - processed_chunk = overlap + chunk - - processed_chunk.strip! - processed_chunk.gsub!(/\n[\n]+/, "\n\n") - - yield processed_chunk, current_metadata - - current_chunk_tokens = tokenizer.encode(chunk) - overlap_token_ids = current_chunk_tokens[-overlap_tokens..-1] || current_chunk_tokens - - overlap = "" - - while overlap_token_ids.present? - begin - padding = split_char - padding = " " if padding.empty? - overlap = tokenizer.decode(overlap_token_ids) + padding - break if overlap.encoding == Encoding::UTF_8 - rescue StandardError - # it is possible that we truncated mid char - end - overlap_token_ids.shift - end - - # remove first word it is probably truncated - overlap = overlap.split(/\s/, 2).last.to_s.lstrip - end - end - - def first_chunk(text, chunk_tokens:, tokenizer:, splitters: ["\n\n", "\n", ".", ""]) - return text, " " if tokenizer.tokenize(text).length <= chunk_tokens - - splitters = splitters.find_all { |s| text.include?(s) }.compact - - buffer = +"" - split_char = nil - - splitters.each do |splitter| - split_char = splitter - - text - .split(split_char) - .each do |part| - break if tokenizer.tokenize(buffer + split_char + part).length > chunk_tokens - buffer << split_char - buffer << part - end - break if buffer.length > 0 - end - - [buffer, split_char] - end - - def get_uploaded_file(upload:, target:) - if %w[png jpg jpeg].include?(upload.extension) && !SiteSetting.ai_rag_images_enabled - raise Discourse::InvalidAccess.new( - "The setting ai_rag_images_enabled is false, can not index images", - ) - end - if upload.extension == "pdf" - return( - DiscourseAi::Utils::PdfToText.as_fake_file( - upload: upload, - llm_model: SiteSetting.ai_rag_images_enabled ? target.rag_llm_model : nil, - user: Discourse.system_user, - ) - ) - end - - if %w[png jpg jpeg].include?(upload.extension) - return( - DiscourseAi::Utils::ImageToText.as_fake_file( - uploads: [upload], - llm_model: target.rag_llm_model, - user: Discourse.system_user, - ) - ) - end - - store = Discourse.store - @file ||= - if store.external? - # Upload#filesize could be approximate. - # add two extra Mbs to make sure that we'll be able to download the upload. - max_filesize = upload.filesize + 2.megabytes - store.download(upload, max_file_size_kb: max_filesize) - else - File.open(store.path_for(upload)) - end - end - end -end diff --git a/app/jobs/regular/fast_track_topic_gist.rb b/app/jobs/regular/fast_track_topic_gist.rb deleted file mode 100644 index a20d1817..00000000 --- a/app/jobs/regular/fast_track_topic_gist.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class FastTrackTopicGist < ::Jobs::Base - sidekiq_options retry: false - - def execute(args) - return if !SiteSetting.discourse_ai_enabled - return if !SiteSetting.ai_summarization_enabled - return if !SiteSetting.ai_summary_gists_enabled - - topic = Topic.find_by(id: args[:topic_id]) - return if topic.blank? - - summarizer = DiscourseAi::Summarization.topic_gist(topic) - gist = summarizer.existing_summary - return if gist.present? && (!gist.outdated || gist.created_at >= 5.minutes.ago) - - summarizer.summarize(Discourse.system_user) - end - end -end diff --git a/app/jobs/regular/generate_chat_thread_title.rb b/app/jobs/regular/generate_chat_thread_title.rb deleted file mode 100644 index 3e8a74b9..00000000 --- a/app/jobs/regular/generate_chat_thread_title.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class GenerateChatThreadTitle < ::Jobs::Base - sidekiq_options queue: "low" - - def execute(args) - return unless SiteSetting.ai_helper_automatic_chat_thread_title - return if (thread_id = args[:thread_id]).blank? - - thread = ::Chat::Thread.find_by_id(thread_id) - return if thread.nil? || thread.title.present? - - title = DiscourseAi::AiHelper::ChatThreadTitler.new(thread).suggested_title - return if title.blank? - - # TODO use a proper API that will make the new title update live - thread.update!(title: title) - end - end -end diff --git a/app/jobs/regular/generate_embeddings.rb b/app/jobs/regular/generate_embeddings.rb deleted file mode 100644 index ab3ea167..00000000 --- a/app/jobs/regular/generate_embeddings.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class GenerateEmbeddings < ::Jobs::Base - sidekiq_options queue: "low" - - def execute(args) - return unless DiscourseAi::Embeddings.enabled? - return if args[:target_type].blank? || args[:target_id].blank? - target = args[:target_type].constantize.find_by_id(args[:target_id]) - return if target.nil? || target.deleted_at.present? - - topic = target.is_a?(Topic) ? target : target.topic - post = target.is_a?(Post) ? target : target.first_post - return if topic.blank? || post.blank? - return if topic.private_message? && !SiteSetting.ai_embeddings_generate_for_pms - return if post.raw.blank? - - DiscourseAi::Embeddings::Vector.instance.generate_representation_from(target) - end - end -end diff --git a/app/jobs/regular/generate_inferred_concepts.rb b/app/jobs/regular/generate_inferred_concepts.rb deleted file mode 100644 index 38038d10..00000000 --- a/app/jobs/regular/generate_inferred_concepts.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class GenerateInferredConcepts < ::Jobs::Base - sidekiq_options queue: "low" - - # Process items to generate new concepts - # - # @param args [Hash] Contains job arguments - # @option args [String] :item_type Required - Type of items to process ('topics' or 'posts') - # @option args [Array] :item_ids Required - List of item IDs to process - # @option args [Integer] :batch_size (100) Number of items to process in each batch - # @option args [Boolean] :match_only (false) Only match against existing concepts without generating new ones - def execute(args = {}) - return if args[:item_ids].blank? || args[:item_type].blank? - - if %w[topics posts].exclude?(args[:item_type]) - Rails.logger.error("Invalid item_type for GenerateInferredConcepts: #{args[:item_type]}") - return - end - - # Process items in smaller batches to avoid memory issues - batch_size = args[:batch_size] || 100 - - # Get the list of item IDs - item_ids = args[:item_ids] - match_only = args[:match_only] || false - - # Process items in batches - item_ids.each_slice(batch_size) do |batch_item_ids| - process_batch(batch_item_ids, args[:item_type], match_only) - end - end - - private - - def process_batch(item_ids, item_type, match_only) - klass = item_type.singularize.classify.constantize - items = klass.where(id: item_ids) - manager = DiscourseAi::InferredConcepts::Manager.new - - items.each do |item| - begin - process_item(item, item_type, match_only, manager) - rescue => e - Rails.logger.error( - "Error generating concepts from #{item_type.singularize} #{item.id}: #{e.message}\n#{e.backtrace.join("\n")}", - ) - end - end - end - - def process_item(item, item_type, match_only, manager) - # Use the Manager method that handles both identifying and creating concepts - if match_only - if item_type == "topics" - manager.match_topic_to_concepts(item) - else # posts - manager.match_post_to_concepts(item) - end - else - if item_type == "topics" - manager.generate_concepts_from_topic(item) - else # posts - manager.generate_concepts_from_post(item) - end - end - end - end -end diff --git a/app/jobs/regular/generate_rag_embeddings.rb b/app/jobs/regular/generate_rag_embeddings.rb deleted file mode 100644 index 775b849f..00000000 --- a/app/jobs/regular/generate_rag_embeddings.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class GenerateRagEmbeddings < ::Jobs::Base - sidekiq_options queue: "ultra_low" - # we could also restrict concurrency but this takes so long if it is not concurrent - - def execute(args) - return if (fragments = RagDocumentFragment.where(id: args[:fragment_ids].to_a)).empty? - - vector = DiscourseAi::Embeddings::Vector.instance - - # generate_representation_from checks compares the digest value to make sure - # the embedding is only generated once per fragment unless something changes. - fragments.map { |fragment| vector.generate_representation_from(fragment) } - - last_fragment = fragments.last - target = last_fragment.target - upload = last_fragment.upload - - indexing_status = RagDocumentFragment.indexing_status(target, [upload])[upload.id] - RagDocumentFragment.publish_status(upload, indexing_status) - end - end -end diff --git a/app/jobs/regular/localize_categories.rb b/app/jobs/regular/localize_categories.rb deleted file mode 100644 index 45476a58..00000000 --- a/app/jobs/regular/localize_categories.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class LocalizeCategories < ::Jobs::Base - cluster_concurrency 1 - sidekiq_options retry: false - - def execute(args) - return if !DiscourseAi::Translation.enabled? - - limit = args[:limit] - raise Discourse::InvalidParameters.new(:limit) if limit.nil? - return if limit <= 0 - locales = SiteSetting.content_localization_supported_locales.split("|") - - categories = Category.where("locale IS NOT NULL") - if SiteSetting.ai_translation_backfill_limit_to_public_content - categories = categories.where(read_restricted: false) - end - categories = categories.order(:id).limit(limit) - return if categories.empty? - - remaining_limit = limit - - categories.each do |category| - break if remaining_limit <= 0 - - existing_locales = CategoryLocalization.where(category_id: category.id).pluck(:locale) - missing_locales = locales - existing_locales - [category.locale] - missing_locales.each do |locale| - break if remaining_limit <= 0 - next if LocaleNormalizer.is_same?(locale, category.locale) - - begin - DiscourseAi::Translation::CategoryLocalizer.localize(category, locale) - rescue FinalDestination::SSRFDetector::LookupFailedError - # do nothing, there are too many sporadic lookup failures - rescue => e - DiscourseAi::Translation::VerboseLogger.log( - "Failed to translate category #{category.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}", - ) - ensure - remaining_limit -= 1 - end - end - - if existing_locales.include?(category.locale) - CategoryLocalization.find_by(category_id: category.id, locale: category.locale).destroy - end - end - end - end -end diff --git a/app/jobs/regular/localize_posts.rb b/app/jobs/regular/localize_posts.rb deleted file mode 100644 index 062974fd..00000000 --- a/app/jobs/regular/localize_posts.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class LocalizePosts < ::Jobs::Base - cluster_concurrency 1 - sidekiq_options retry: false - - def execute(args) - limit = args[:limit] - raise Discourse::InvalidParameters.new(:limit) if limit.blank? || limit <= 0 - - return if !DiscourseAi::Translation.backfill_enabled? - - locales = SiteSetting.content_localization_supported_locales.split("|") - locales.each do |locale| - base_locale = locale.split("_").first - posts = - Post - .joins( - "LEFT JOIN post_localizations pl ON pl.post_id = posts.id AND pl.locale LIKE '#{base_locale}%'", - ) - .where( - "posts.created_at > ?", - SiteSetting.ai_translation_backfill_max_age_days.days.ago, - ) - .where(deleted_at: nil) - .where("posts.user_id > 0") - .where.not(raw: [nil, ""]) - .where.not(locale: nil) - .where("posts.locale NOT LIKE '#{base_locale}%'") - .where("pl.id IS NULL") - - posts = posts.joins(:topic) - - if SiteSetting.ai_translation_backfill_limit_to_public_content - # exclude all PMs - # and only include posts from public categories - posts = - posts - .where.not(topics: { archetype: Archetype.private_message }) - .where(topics: { category_id: Category.where(read_restricted: false).select(:id) }) - else - # all regular topics, and group PMs - posts = - posts.where( - "topics.archetype != ? OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)", - Archetype.private_message, - ) - end - - posts = posts.order(updated_at: :desc).limit(limit) - - next if posts.empty? - - posts.each do |post| - begin - DiscourseAi::Translation::PostLocalizer.localize(post, locale) - rescue FinalDestination::SSRFDetector::LookupFailedError - # do nothing, there are too many sporadic lookup failures - rescue => e - DiscourseAi::Translation::VerboseLogger.log( - "Failed to translate post #{post.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}", - ) - end - end - - DiscourseAi::Translation::VerboseLogger.log("Translated #{posts.size} posts to #{locale}") - end - end - end -end diff --git a/app/jobs/regular/localize_topics.rb b/app/jobs/regular/localize_topics.rb deleted file mode 100644 index 0fa334d3..00000000 --- a/app/jobs/regular/localize_topics.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class LocalizeTopics < ::Jobs::Base - cluster_concurrency 1 - sidekiq_options retry: false - - def execute(args) - limit = args[:limit] - raise Discourse::InvalidParameters.new(:limit) if limit.blank? || limit <= 0 - - return if !DiscourseAi::Translation.backfill_enabled? - - locales = SiteSetting.content_localization_supported_locales.split("|") - locales.each do |locale| - base_locale = locale.split("_").first - topics = - Topic - .joins( - "LEFT JOIN topic_localizations tl ON tl.topic_id = topics.id AND tl.locale LIKE '#{base_locale}%'", - ) - .where( - "topics.created_at > ?", - SiteSetting.ai_translation_backfill_max_age_days.days.ago, - ) - .where(deleted_at: nil) - .where("topics.user_id > 0") - .where.not(locale: nil) - .where("topics.locale NOT LIKE '#{base_locale}%'") - .where("tl.id IS NULL") - - if SiteSetting.ai_translation_backfill_limit_to_public_content - # exclude all PMs - # and only include posts from public categories - topics = - topics - .where.not(archetype: Archetype.private_message) - .where(category_id: Category.where(read_restricted: false).select(:id)) - else - # all regular topics, and group PMs - topics = - topics.where( - "topics.archetype != ? OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)", - Archetype.private_message, - ) - end - - topics = topics.order(updated_at: :desc).limit(limit) - - next if topics.empty? - - topics.each do |topic| - begin - DiscourseAi::Translation::TopicLocalizer.localize(topic, locale) - rescue FinalDestination::SSRFDetector::LookupFailedError - # do nothing, there are too many sporadic lookup failures - rescue => e - DiscourseAi::Translation::VerboseLogger.log( - "Failed to translate topic #{topic.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}", - ) - end - end - - DiscourseAi::Translation::VerboseLogger.log("Translated #{topics.size} topics to #{locale}") - end - end - end -end diff --git a/app/jobs/regular/manage_embedding_def_search_index.rb b/app/jobs/regular/manage_embedding_def_search_index.rb deleted file mode 100644 index 5baa526c..00000000 --- a/app/jobs/regular/manage_embedding_def_search_index.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class ManageEmbeddingDefSearchIndex < ::Jobs::Base - def execute(args) - embedding_def = EmbeddingDefinition.find_by(id: args[:id]) - return if embedding_def.nil? - return if DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_def) - - DiscourseAi::Embeddings::Schema.prepare_search_indexes(embedding_def) - end - end -end diff --git a/app/jobs/regular/post_sentiment_analysis.rb b/app/jobs/regular/post_sentiment_analysis.rb deleted file mode 100644 index 59b96a1b..00000000 --- a/app/jobs/regular/post_sentiment_analysis.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class PostSentimentAnalysis < ::Jobs::Base - sidekiq_options queue: "low" - - def execute(args) - return unless SiteSetting.ai_sentiment_enabled - return if (post_id = args[:post_id]).blank? - - post = Post.find_by(id: post_id, post_type: Post.types[:regular]) - return if post&.raw.blank? - - DiscourseAi::Sentiment::PostClassification.new.classify!(post) - end - end -end diff --git a/app/jobs/regular/shared_conversation_adjust_upload_security.rb b/app/jobs/regular/shared_conversation_adjust_upload_security.rb deleted file mode 100644 index 350ca0e7..00000000 --- a/app/jobs/regular/shared_conversation_adjust_upload_security.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class SharedConversationAdjustUploadSecurity < ::Jobs::Base - def execute(args) - if args[:conversation_id].present? - # The conversation context includes post cooked content so this - # must be updated when target uploads security changes. - update_conversation(args[:conversation_id]) - elsif args[:target_id].present? && args[:target_type].present? - # If we deleted the conversation then we just need to update the target's - # uploads security, no need to update the conversation. - update_target(args[:target_id], args[:target_type]) - end - end - - private - - def update_conversation(conversation_id) - conversation = SharedAiConversation.find_by(id: conversation_id) - return if conversation.blank? - - # NOTE: Only Topics are supported for now, in future we will need a more flexible - # way of doing this. - if conversation.target_type == "Topic" - rebaked_posts = TopicUploadSecurityManager.new(conversation.target).run - - if rebaked_posts.any? - new_context = - conversation.context.map do |context_post| - rebaked_post = rebaked_posts.find { |p| p.id == context_post["id"] } - context_post["cooked"] = rebaked_post.cooked if rebaked_post - context_post - end - - conversation.update(context: new_context) - end - end - end - - def update_target(target_id, target_type) - # NOTE: Only Topics are supported for now, in future we will need a more flexible - # way of doing this. - if target_type == "Topic" - topic = target_type.constantize.find_by(id: target_id) - return if topic.blank? - TopicUploadSecurityManager.new(topic).run - end - end - end -end diff --git a/app/jobs/regular/stream_composer_helper.rb b/app/jobs/regular/stream_composer_helper.rb deleted file mode 100644 index b7e9b33f..00000000 --- a/app/jobs/regular/stream_composer_helper.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class StreamComposerHelper < ::Jobs::Base - sidekiq_options retry: false - - def execute(args) - return unless args[:prompt] - return unless user = User.find_by(id: args[:user_id]) - return unless args[:text] - return unless args[:client_id] - return unless args[:progress_channel] - - helper_mode = args[:prompt] - - DiscourseAi::AiHelper::Assistant.new.stream_prompt( - helper_mode, - args[:text], - user, - args[:progress_channel], - force_default_locale: args[:force_default_locale], - client_id: args[:client_id], - custom_prompt: args[:custom_prompt], - ) - end - end -end diff --git a/app/jobs/regular/stream_discord_reply.rb b/app/jobs/regular/stream_discord_reply.rb deleted file mode 100644 index 7029a080..00000000 --- a/app/jobs/regular/stream_discord_reply.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class StreamDiscordReply < ::Jobs::Base - sidekiq_options retry: false - - def execute(args) - interaction = args[:interaction] - - return unless SiteSetting.ai_discord_search_enabled - - if SiteSetting.ai_discord_search_mode == "persona" - DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction! - else - DiscourseAi::Discord::Bot::Search.new(interaction).handle_interaction! - end - end - end -end diff --git a/app/jobs/regular/stream_discover_reply.rb b/app/jobs/regular/stream_discover_reply.rb deleted file mode 100644 index 3e6e9594..00000000 --- a/app/jobs/regular/stream_discover_reply.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class StreamDiscoverReply < ::Jobs::Base - sidekiq_options retry: false - - def execute(args) - return if (user = User.find_by(id: args[:user_id])).nil? - return if (query = args[:query]).blank? - - ai_persona_klass = - AiPersona - .all_personas(enabled_only: false) - .find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i } - - if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a) - return - end - return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil? - - bot = - DiscourseAi::Personas::Bot.as( - Discourse.system_user, - persona: ai_persona_klass.new, - model: llm_model, - ) - - streamed_reply = +"" - start = Time.now - - base = { query: query, model_used: llm_model.display_name } - - context = - DiscourseAi::Personas::BotContext.new( - messages: [{ type: :user, content: query }], - skip_tool_details: true, - ) - - bot.reply(context) do |partial| - streamed_reply << partial - - # Throttle updates. - if (Time.now - start > 0.3) || Rails.env.test? - payload = base.merge(done: false, ai_discover_reply: streamed_reply) - publish_update(user, payload) - start = Time.now - end - end - - publish_update(user, base.merge(done: true, ai_discover_reply: streamed_reply)) - end - - def publish_update(user, payload) - MessageBus.publish("/discourse-ai/ai-bot/discover", payload, user_ids: [user.id]) - end - end -end diff --git a/app/jobs/regular/stream_post_helper.rb b/app/jobs/regular/stream_post_helper.rb deleted file mode 100644 index a1cca62c..00000000 --- a/app/jobs/regular/stream_post_helper.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class StreamPostHelper < ::Jobs::Base - sidekiq_options retry: false - - def execute(args) - return unless post = Post.includes(:topic).find_by(id: args[:post_id]) - return unless user = User.find_by(id: args[:user_id]) - return unless args[:text] - return unless args[:progress_channel] - return unless args[:client_id] - - topic = post.topic - reply_to = post.reply_to_post - - return unless user.guardian.can_see?(post) - - helper_mode = args[:prompt] - - if helper_mode == DiscourseAi::AiHelper::Assistant::EXPLAIN - input = <<~TEXT.strip - #{args[:text]} - #{post.raw} - #{topic.title} - #{reply_to ? "#{reply_to.raw}" : nil} - TEXT - else - input = args[:text] - end - - DiscourseAi::AiHelper::Assistant.new.stream_prompt( - helper_mode, - input, - user, - args[:progress_channel], - custom_prompt: args[:custom_prompt], - client_id: args[:client_id], - ) - end - end -end diff --git a/app/jobs/regular/stream_topic_ai_summary.rb b/app/jobs/regular/stream_topic_ai_summary.rb deleted file mode 100644 index 51e4be63..00000000 --- a/app/jobs/regular/stream_topic_ai_summary.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class StreamTopicAiSummary < ::Jobs::Base - sidekiq_options retry: false - - def execute(args) - return unless topic = Topic.find_by(id: args[:topic_id]) - return unless user = User.find_by(id: args[:user_id]) - - strategy = DiscourseAi::Summarization.topic_summary(topic) - return if strategy.nil? || !Guardian.new(user).can_see_summary?(topic) - - guardian = Guardian.new(user) - return unless guardian.can_see?(topic) - - skip_age_check = !!args[:skip_age_check] - - streamed_summary = +"" - start = Time.now - - summary = - DiscourseAi::TopicSummarization - .new(strategy, user) - .summarize(skip_age_check: skip_age_check) do |partial_summary| - streamed_summary << partial_summary - - # Throttle updates. - if (Time.now - start > 0.3) || Rails.env.test? - payload = { done: false, ai_topic_summary: { summarized_text: streamed_summary } } - - publish_update(topic, user, payload) - start = Time.now - end - end - - publish_update( - topic, - user, - AiTopicSummarySerializer.new(summary, { scope: guardian }).as_json.merge(done: true), - ) - end - - private - - def publish_update(topic, user, payload) - MessageBus.publish("/discourse-ai/summaries/topic/#{topic.id}", payload, user_ids: [user.id]) - end - end -end diff --git a/app/jobs/scheduled/categories_locale_detection_backfill.rb b/app/jobs/scheduled/categories_locale_detection_backfill.rb deleted file mode 100644 index ae53817b..00000000 --- a/app/jobs/scheduled/categories_locale_detection_backfill.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class CategoriesLocaleDetectionBackfill < ::Jobs::Scheduled - every 1.hour - sidekiq_options retry: false - cluster_concurrency 1 - - def execute(args) - return if !DiscourseAi::Translation.backfill_enabled? - - categories = Category.where(locale: nil) - - if SiteSetting.ai_translation_backfill_limit_to_public_content - categories = categories.where(read_restricted: false) - end - - limit = SiteSetting.ai_translation_backfill_hourly_rate - categories = categories.limit(limit) - return if categories.empty? - - categories.each do |category| - begin - DiscourseAi::Translation::CategoryLocaleDetector.detect_locale(category) - rescue FinalDestination::SSRFDetector::LookupFailedError - rescue => e - DiscourseAi::Translation::VerboseLogger.log( - "Failed to detect category #{category.id}'s locale: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}", - ) - end - end - - DiscourseAi::Translation::VerboseLogger.log("Detected #{categories.size} category locales") - end - end -end diff --git a/app/jobs/scheduled/category_localization_backfill.rb b/app/jobs/scheduled/category_localization_backfill.rb deleted file mode 100644 index 520b3349..00000000 --- a/app/jobs/scheduled/category_localization_backfill.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class CategoryLocalizationBackfill < ::Jobs::Scheduled - every 1.hour - cluster_concurrency 1 - - def execute(args) - return if !DiscourseAi::Translation.backfill_enabled? - limit = SiteSetting.ai_translation_backfill_hourly_rate - - Jobs.enqueue(:localize_categories, limit:) - end - end -end diff --git a/app/jobs/scheduled/embeddings_backfill.rb b/app/jobs/scheduled/embeddings_backfill.rb deleted file mode 100644 index 163534cc..00000000 --- a/app/jobs/scheduled/embeddings_backfill.rb +++ /dev/null @@ -1,151 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class EmbeddingsBackfill < ::Jobs::Scheduled - every 5.minutes - sidekiq_options queue: "low" - cluster_concurrency 1 - - def execute(args) - return unless DiscourseAi::Embeddings.enabled? - - limit = SiteSetting.ai_embeddings_backfill_batch_size - - if limit > 50_000 - limit = 50_000 - Rails.logger.warn( - "Limiting backfill batch size to 50,000 to avoid OOM errors, reduce ai_embeddings_backfill_batch_size to avoid this warning", - ) - end - - production_vector = DiscourseAi::Embeddings::Vector.instance - - if SiteSetting.ai_embeddings_backfill_model.present? && - SiteSetting.ai_embeddings_backfill_model != SiteSetting.ai_embeddings_selected_model - backfill_vector = - DiscourseAi::Embeddings::Vector.new( - EmbeddingDefinition.find_by(id: SiteSetting.ai_embeddings_backfill_model), - ) - end - - topic_work_list = [] - topic_work_list << production_vector - topic_work_list << backfill_vector if backfill_vector - - topic_work_list.each do |vector| - rebaked = 0 - table_name = DiscourseAi::Embeddings::Schema::TOPICS_TABLE - vector_def = vector.vdef - - topics = - Topic - .joins( - "LEFT JOIN #{table_name} ON #{table_name}.topic_id = topics.id AND #{table_name}.model_id = #{vector_def.id}", - ) - .where(archetype: Archetype.default) - .where(deleted_at: nil) - .order("topics.bumped_at DESC") - - rebaked += populate_topic_embeddings(vector, topics.limit(limit - rebaked)) - - next if rebaked >= limit - - # Then, we'll try to backfill embeddings for topics that have outdated - # embeddings, be it model or strategy version - relation = topics.where(<<~SQL).limit(limit - rebaked) - #{table_name}.model_version < #{vector_def.version} - OR - #{table_name}.strategy_version < #{vector_def.strategy_version} - SQL - - rebaked += populate_topic_embeddings(vector, relation, force: true) - - next if rebaked >= limit - - # Finally, we'll try to backfill embeddings for topics that have outdated - # embeddings due to edits or new replies. Here we only do 10% of the limit - relation = - topics - .where("#{table_name}.updated_at < ?", 6.hours.ago) - .where("#{table_name}.updated_at < topics.updated_at") - .limit((limit - rebaked) / 10) - - populate_topic_embeddings(vector, relation, force: true) - - next unless SiteSetting.ai_embeddings_per_post_enabled - - # Now for posts - table_name = DiscourseAi::Embeddings::Schema::POSTS_TABLE - posts_batch_size = 1000 - - posts = - Post - .joins( - "LEFT JOIN #{table_name} ON #{table_name}.post_id = posts.id AND #{table_name}.model_id = #{vector_def.id}", - ) - .where(deleted_at: nil) - .where(post_type: Post.types[:regular]) - - # First, we'll try to backfill embeddings for posts that have none - posts - .where("#{table_name}.post_id IS NULL") - .limit(limit - rebaked) - .pluck(:id) - .each_slice(posts_batch_size) do |batch| - vector.gen_bulk_reprensentations(Post.where(id: batch)) - rebaked += batch.length - end - - next if rebaked >= limit - - # Then, we'll try to backfill embeddings for posts that have outdated - # embeddings, be it model or strategy version - posts - .where(<<~SQL) - #{table_name}.model_version < #{vector_def.version} - OR - #{table_name}.strategy_version < #{vector_def.strategy_version} - SQL - .limit(limit - rebaked) - .pluck(:id) - .each_slice(posts_batch_size) do |batch| - vector.gen_bulk_reprensentations(Post.where(id: batch)) - rebaked += batch.length - end - - next if rebaked >= limit - - # Finally, we'll try to backfill embeddings for posts that have outdated - # embeddings due to edits. Here we only do 10% of the limit - posts - .where("#{table_name}.updated_at < ?", 7.days.ago) - .order("random()") - .limit((limit - rebaked) / 10) - .pluck(:id) - .each_slice(posts_batch_size) do |batch| - vector.gen_bulk_reprensentations(Post.where(id: batch)) - rebaked += batch.length - end - end - end - - private - - def populate_topic_embeddings(vector, topics, force: false) - done = 0 - - topics = - topics.where("#{DiscourseAi::Embeddings::Schema::TOPICS_TABLE}.topic_id IS NULL") if !force - - ids = topics.pluck("topics.id") - batch_size = 1000 - - ids.each_slice(batch_size) do |batch| - vector.gen_bulk_reprensentations(Topic.where(id: batch).order("topics.bumped_at DESC")) - done += batch.length - end - - done - end - end -end diff --git a/app/jobs/scheduled/generate_concepts_from_popular_items.rb b/app/jobs/scheduled/generate_concepts_from_popular_items.rb deleted file mode 100644 index 71f0a58e..00000000 --- a/app/jobs/scheduled/generate_concepts_from_popular_items.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class GenerateConceptsFromPopularItems < ::Jobs::Scheduled - every 1.day - - # This job runs daily and generates new concepts from popular topics and posts - # It selects items based on engagement metrics and generates concepts from their content - def execute(_args) - return unless SiteSetting.inferred_concepts_enabled - - process_popular_topics - process_popular_posts - end - - private - - def process_popular_topics - # Find candidate topics that are popular and don't have concepts yet - manager = DiscourseAi::InferredConcepts::Manager.new - candidates = - manager.find_candidate_topics( - limit: SiteSetting.inferred_concepts_daily_topics_limit || 20, - min_posts: SiteSetting.inferred_concepts_min_posts || 5, - min_likes: SiteSetting.inferred_concepts_min_likes || 10, - min_views: SiteSetting.inferred_concepts_min_views || 100, - created_after: SiteSetting.inferred_concepts_lookback_days.days.ago, - ) - - return if candidates.blank? - - # Process candidate topics - first generate concepts, then match - Jobs.enqueue( - :generate_inferred_concepts, - item_type: "topics", - item_ids: candidates.map(&:id), - batch_size: 10, - ) - - if SiteSetting.inferred_concepts_background_match - # Schedule a follow-up job to match existing concepts - Jobs.enqueue_in( - 1.hour, - :generate_inferred_concepts, - item_type: "topics", - item_ids: candidates.map(&:id), - batch_size: 10, - match_only: true, - ) - end - end - - def process_popular_posts - # Find candidate posts that are popular and don't have concepts yet - manager = DiscourseAi::InferredConcepts::Manager.new - candidates = - manager.find_candidate_posts( - limit: SiteSetting.inferred_concepts_daily_posts_limit || 30, - min_likes: SiteSetting.inferred_concepts_post_min_likes || 5, - exclude_first_posts: true, - created_after: SiteSetting.inferred_concepts_lookback_days.days.ago, - ) - - return if candidates.blank? - - # Process candidate posts - first generate concepts, then match - Jobs.enqueue( - :generate_inferred_concepts, - item_type: "posts", - item_ids: candidates.map(&:id), - batch_size: 10, - ) - - if SiteSetting.inferred_concepts_background_match - # Schedule a follow-up job to match against existing concepts - Jobs.enqueue_in( - 1.hour, - :generate_inferred_concepts, - item_type: "posts", - item_ids: candidates.map(&:id), - batch_size: 10, - match_only: true, - ) - end - end - end -end diff --git a/app/jobs/scheduled/post_localization_backfill.rb b/app/jobs/scheduled/post_localization_backfill.rb deleted file mode 100644 index 522328be..00000000 --- a/app/jobs/scheduled/post_localization_backfill.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class PostLocalizationBackfill < ::Jobs::Scheduled - every 5.minutes - cluster_concurrency 1 - - def execute(args) - return if !DiscourseAi::Translation.backfill_enabled? - - limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals - return if limit == 0 - - Jobs.enqueue(:localize_posts, limit:) - end - end -end diff --git a/app/jobs/scheduled/posts_locale_detection_backfill.rb b/app/jobs/scheduled/posts_locale_detection_backfill.rb deleted file mode 100644 index 0a96cd3e..00000000 --- a/app/jobs/scheduled/posts_locale_detection_backfill.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class PostsLocaleDetectionBackfill < ::Jobs::Scheduled - every 5.minutes - sidekiq_options retry: false - cluster_concurrency 1 - - def execute(args) - return if !DiscourseAi::Translation.backfill_enabled? - - limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals - - posts = - Post - .where(locale: nil) - .where(deleted_at: nil) - .where("posts.user_id > 0") - .where("posts.created_at > ?", SiteSetting.ai_translation_backfill_max_age_days.days.ago) - .where.not(raw: [nil, ""]) - - if SiteSetting.ai_translation_backfill_limit_to_public_content - posts = - posts - .joins(:topic) - .where(topics: { category_id: Category.where(read_restricted: false).select(:id) }) - .where("archetype != ?", Archetype.private_message) - else - posts = - posts.joins(:topic).where( - "topics.archetype != ? OR EXISTS (SELECT 1 FROM topic_allowed_groups WHERE topic_id = topics.id)", - Archetype.private_message, - ) - end - - posts = posts.order(updated_at: :desc).limit(limit) - return if posts.empty? - - posts.each do |post| - begin - DiscourseAi::Translation::PostLocaleDetector.detect_locale(post) - rescue FinalDestination::SSRFDetector::LookupFailedError - # do nothing, there are too many sporadic lookup failures - rescue => e - DiscourseAi::Translation::VerboseLogger.log( - "Failed to detect post #{post.id}'s locale: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}", - ) - end - end - - DiscourseAi::Translation::VerboseLogger.log("Detected #{posts.size} post locales") - end - end -end diff --git a/app/jobs/scheduled/remove_orphaned_embeddings.rb b/app/jobs/scheduled/remove_orphaned_embeddings.rb deleted file mode 100644 index d5fddcf6..00000000 --- a/app/jobs/scheduled/remove_orphaned_embeddings.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class RemoveOrphanedEmbeddings < ::Jobs::Scheduled - every 1.week - - def execute(_args) - DiscourseAi::Embeddings::Schema.remove_orphaned_data - end - end -end diff --git a/app/jobs/scheduled/sentiment_backfill.rb b/app/jobs/scheduled/sentiment_backfill.rb deleted file mode 100644 index 5f952676..00000000 --- a/app/jobs/scheduled/sentiment_backfill.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class SentimentBackfill < ::Jobs::Scheduled - every 5.minutes - cluster_concurrency 1 - - def execute(_args) - return if !SiteSetting.ai_sentiment_enabled - - base_budget = SiteSetting.ai_sentiment_backfill_maximum_posts_per_hour - return if base_budget.zero? - # Split budget in 12 intervals, but make sure is at least one. - # - # This is not exact as we don't have a way of tracking how many - # posts we classified in the current hour, but it's a good enough approximation. - limit_per_job = [base_budget, 12].max / 12 - - classificator = DiscourseAi::Sentiment::PostClassification.new - return if !classificator.has_classifiers? - - posts = - DiscourseAi::Sentiment::PostClassification.backfill_query( - max_age_days: SiteSetting.ai_sentiment_backfill_post_max_age_days, - ).limit(limit_per_job) - - classificator.bulk_classify!(posts) - end - end -end diff --git a/app/jobs/scheduled/summaries_backfill.rb b/app/jobs/scheduled/summaries_backfill.rb deleted file mode 100644 index d5a241e4..00000000 --- a/app/jobs/scheduled/summaries_backfill.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -module ::Jobs - class SummariesBackfill < ::Jobs::Scheduled - every 5.minutes - cluster_concurrency 1 - - def execute(_args) - return if !SiteSetting.discourse_ai_enabled - return if !SiteSetting.ai_summarization_enabled - return if SiteSetting.ai_summary_backfill_maximum_topics_per_hour.zero? - - system_user = Discourse.system_user - - if SiteSetting.ai_summary_gists_enabled - gist_t = AiSummary.summary_types[:gist] - - backfill_candidates(gist_t) - .limit(current_budget(gist_t)) - .each do |topic| - strategy = DiscourseAi::Summarization.topic_gist(topic) - try_summarize(strategy, system_user, topic) - end - end - - complete_t = AiSummary.summary_types[:complete] - backfill_candidates(complete_t) - .limit(current_budget(complete_t)) - .each do |topic| - strategy = DiscourseAi::Summarization.topic_summary(topic) - try_summarize(strategy, system_user, topic) - end - end - - def try_summarize(strategy, user, topic) - existing_summary = strategy.existing_summary - - if existing_summary.blank? || existing_summary.outdated - begin - strategy.summarize(user) - rescue => e - Rails.logger.error("Error summarizing topic #{topic.id}: #{e.class.name} - #{e.message}") - raise e - end - else - # Hiding or deleting a post, and creating a small action alters the Topic#highest_post_number. - # We use this as a quick way to select potential backfill candidates without relying on original_content_sha. - # At this point, we are confident the summary doesn't need to be regenerated so something other than a regular reply - # caused the number to change in the topic. - existing_summary.update!(highest_target_number: topic.highest_post_number) - end - end - - def backfill_candidates(summary_type) - max_age_days = SiteSetting.ai_summary_backfill_topic_max_age_days - - Topic - .where("topics.word_count >= ?", SiteSetting.ai_summary_backfill_minimum_word_count) - .joins(<<~SQL) - LEFT OUTER JOIN ai_summaries ais ON - topics.id = ais.target_id AND - ais.target_type = 'Topic' AND - ais.summary_type = '#{summary_type}' - SQL - .where("topics.last_posted_at > current_timestamp - INTERVAL '#{max_age_days.to_i} DAY'") - .where( - <<~SQL, # (1..1) gets stored ad (1..2). - ais.id IS NULL OR ( - ais.highest_target_number < topics.highest_post_number - AND ais.updated_at < (current_timestamp - INTERVAL '5 minutes') - ) - SQL - ) - .order("ais.updated_at DESC NULLS FIRST, topics.last_posted_at DESC") - end - - def current_budget(type) - # Split budget in 12 intervals, but make sure is at least one. - base_budget = SiteSetting.ai_summary_backfill_maximum_topics_per_hour - limit_per_job = [base_budget, 12].max / 12 - - used_budget = - AiSummary.system.where("created_at > ?", 1.hour.ago).where(summary_type: type).count - - current_budget = [(base_budget - used_budget), limit_per_job].min - return 0 if current_budget < 0 - - current_budget - end - end -end diff --git a/app/jobs/scheduled/topic_localization_backfill.rb b/app/jobs/scheduled/topic_localization_backfill.rb deleted file mode 100644 index d53e20dc..00000000 --- a/app/jobs/scheduled/topic_localization_backfill.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class TopicLocalizationBackfill < ::Jobs::Scheduled - every 5.minutes - cluster_concurrency 1 - - def execute(args) - return if !DiscourseAi::Translation.backfill_enabled? - - limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals - Jobs.enqueue(:localize_topics, limit:) - end - end -end diff --git a/app/jobs/scheduled/topics_locale_detection_backfill.rb b/app/jobs/scheduled/topics_locale_detection_backfill.rb deleted file mode 100644 index c917ddb6..00000000 --- a/app/jobs/scheduled/topics_locale_detection_backfill.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class TopicsLocaleDetectionBackfill < ::Jobs::Scheduled - every 5.minutes - sidekiq_options retry: false - cluster_concurrency 1 - - def execute(args) - return if !DiscourseAi::Translation.backfill_enabled? - - limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals - - topics = - Topic - .where(locale: nil, deleted_at: nil) - .where("topics.user_id > 0") - .where("topics.created_at > ?", SiteSetting.ai_translation_backfill_max_age_days.days.ago) - - if SiteSetting.ai_translation_backfill_limit_to_public_content - topics = - topics.where(category_id: Category.where(read_restricted: false).select(:id)).where( - "archetype != ?", - Archetype.private_message, - ) - else - topics = - topics.where( - "archetype != ? OR EXISTS (SELECT 1 FROM topic_allowed_groups WHERE topic_id = topics.id)", - Archetype.private_message, - ) - end - - topics = topics.order(updated_at: :desc).limit(limit) - return if topics.empty? - - topics.each do |topic| - begin - DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic) - rescue FinalDestination::SSRFDetector::LookupFailedError - # do nothing, there are too many sporadic lookup failures - rescue => e - DiscourseAi::Translation::VerboseLogger.log( - "Failed to detect topic #{topic.id}'s locale: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}", - ) - end - end - - DiscourseAi::Translation::VerboseLogger.log("Detected #{topics.size} topic locales") - end - end -end diff --git a/app/mailers/ai_report_mailer.rb b/app/mailers/ai_report_mailer.rb deleted file mode 100644 index fada125e..00000000 --- a/app/mailers/ai_report_mailer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AiReportMailer < ActionMailer::Base - include Email::BuildEmailHelper - - def send_report(to_address, opts = {}) - build_email(to_address, **opts) - end -end diff --git a/app/models/ai_api_audit_log.rb b/app/models/ai_api_audit_log.rb deleted file mode 100644 index 69f72267..00000000 --- a/app/models/ai_api_audit_log.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -class AiApiAuditLog < ActiveRecord::Base - belongs_to :post - belongs_to :topic - belongs_to :user - - module Provider - OpenAI = 1 - Anthropic = 2 - HuggingFaceTextGeneration = 3 - Gemini = 4 - Vllm = 5 - Cohere = 6 - Ollama = 7 - SambaNova = 8 - Mistral = 9 - OpenRouter = 10 - end - - def next_log_id - self.class.where("id > ?", id).where(topic_id: topic_id).order(id: :asc).pluck(:id).first - end - - def prev_log_id - self.class.where("id < ?", id).where(topic_id: topic_id).order(id: :desc).pluck(:id).first - end -end - -# == Schema Information -# -# Table name: ai_api_audit_logs -# -# id :bigint not null, primary key -# provider_id :integer not null -# user_id :integer -# request_tokens :integer -# response_tokens :integer -# raw_request_payload :string -# raw_response_payload :string -# created_at :datetime not null -# updated_at :datetime not null -# topic_id :integer -# post_id :integer -# feature_name :string(255) -# language_model :string(255) -# feature_context :jsonb -# cached_tokens :integer -# duration_msecs :integer -# -# Indexes -# -# index_ai_api_audit_logs_on_created_at_and_feature_name (created_at,feature_name) -# index_ai_api_audit_logs_on_created_at_and_language_model (created_at,language_model) -# diff --git a/app/models/ai_artifact.rb b/app/models/ai_artifact.rb deleted file mode 100644 index dab0acae..00000000 --- a/app/models/ai_artifact.rb +++ /dev/null @@ -1,119 +0,0 @@ -# frozen_string_literal: true - -class AiArtifact < ActiveRecord::Base - has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy - has_many :key_values, class_name: "AiArtifactKeyValue", dependent: :destroy - belongs_to :user - belongs_to :post - validates :html, length: { maximum: 65_535 } - validates :css, length: { maximum: 65_535 } - validates :js, length: { maximum: 65_535 } - - ALLOWED_CDN_SOURCES = %w[ - https://cdn.jsdelivr.net - https://cdnjs.cloudflare.com - https://unpkg.com - https://ajax.googleapis.com - https://d3js.org - https://code.jquery.com - https://esm.sh - ] - - def self.artifact_version_attribute(version) - if version - "data-artifact-version='#{version}'" - else - "" - end - end - - def self.iframe_for(id, version = nil) - <<~HTML - - HTML - end - - def self.url(id, version = nil) - url = Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}" - if version - "#{url}/#{version}" - else - url - end - end - - def self.share_publicly(id:, post:) - artifact = AiArtifact.find_by(id: id) - if artifact&.post&.topic&.id == post.topic.id - artifact.metadata ||= {} - artifact.metadata[:public] = true - artifact.save! - end - end - - def self.unshare_publicly(id:) - artifact = AiArtifact.find_by(id: id) - artifact&.update!(metadata: { public: false }) - end - - def url - self.class.url(id) - end - - def apply_diff(html_diff: nil, css_diff: nil, js_diff: nil, change_description: nil) - differ = DiscourseAi::Utils::DiffUtils - - html = html_diff ? differ.apply_hunk(self.html, html_diff) : self.html - css = css_diff ? differ.apply_hunk(self.css, css_diff) : self.css - js = js_diff ? differ.apply_hunk(self.js, js_diff) : self.js - - create_new_version(html: html, css: css, js: js, change_description: change_description) - end - - def create_new_version(html: nil, css: nil, js: nil, change_description: nil) - latest_version = versions.order(version_number: :desc).first - new_version_number = latest_version ? latest_version.version_number + 1 : 1 - version = nil - - transaction do - # Create the version record - version = - versions.create!( - version_number: new_version_number, - html: html || self.html, - css: css || self.css, - js: js || self.js, - change_description: change_description, - ) - save! - end - - version - end - - def public? - !!metadata&.dig("public") - end -end - -# == Schema Information -# -# Table name: ai_artifacts -# -# id :bigint not null, primary key -# user_id :integer not null -# post_id :integer not null -# name :string(255) not null -# html :string(65535) -# css :string(65535) -# js :string(65535) -# metadata :jsonb -# created_at :datetime not null -# updated_at :datetime not null -# diff --git a/app/models/ai_artifact_key_value.rb b/app/models/ai_artifact_key_value.rb deleted file mode 100644 index fdfff90d..00000000 --- a/app/models/ai_artifact_key_value.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -class AiArtifactKeyValue < ActiveRecord::Base - belongs_to :ai_artifact - belongs_to :user - - validates :key, presence: true, length: { maximum: 50 } - validates :value, - presence: true, - length: { - maximum: ->(_) { SiteSetting.ai_artifact_kv_value_max_length }, - } - attribute :public, :boolean, default: false - validates :ai_artifact, presence: true - validates :user, presence: true - validates :key, uniqueness: { scope: %i[ai_artifact_id user_id] } - - validate :validate_max_keys_per_user_per_artifact - - private - - def validate_max_keys_per_user_per_artifact - return unless ai_artifact_id && user_id - - max_keys = SiteSetting.ai_artifact_max_keys_per_user_per_artifact - existing_count = self.class.where(ai_artifact_id: ai_artifact_id, user_id: user_id).count - - # Don't count the current record if it's being updated - existing_count -= 1 if persisted? - - if existing_count >= max_keys - errors.add( - :base, - I18n.t("discourse_ai.ai_artifact.errors.max_keys_exceeded", count: max_keys), - ) - end - end -end - -# == Schema Information -# -# Table name: ai_artifact_key_values -# -# id :bigint not null, primary key -# ai_artifact_id :bigint not null -# user_id :integer not null -# key :string(50) not null -# value :string(20000) not null -# public :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_ai_artifact_kv_unique (ai_artifact_id,user_id,key) UNIQUE -# diff --git a/app/models/ai_artifact_version.rb b/app/models/ai_artifact_version.rb deleted file mode 100644 index 9a7d2b14..00000000 --- a/app/models/ai_artifact_version.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true -class AiArtifactVersion < ActiveRecord::Base - belongs_to :ai_artifact - validates :html, length: { maximum: 65_535 } - validates :css, length: { maximum: 65_535 } - validates :js, length: { maximum: 65_535 } - - # used when generating test cases - def write_to(path) - css_path = "#{path}/main.css" - html_path = "#{path}/main.html" - js_path = "#{path}/main.js" - instructions_path = "#{path}/instructions.txt" - - File.write(css_path, css) - File.write(html_path, html) - File.write(js_path, js) - File.write(instructions_path, change_description) - end -end - -# == Schema Information -# -# Table name: ai_artifact_versions -# -# id :bigint not null, primary key -# ai_artifact_id :bigint not null -# version_number :integer not null -# html :string(65535) -# css :string(65535) -# js :string(65535) -# metadata :jsonb -# change_description :string -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_ai_artifact_versions_on_ai_artifact_id_and_version_number (ai_artifact_id,version_number) UNIQUE -# diff --git a/app/models/ai_moderation_setting.rb b/app/models/ai_moderation_setting.rb deleted file mode 100644 index 596ba131..00000000 --- a/app/models/ai_moderation_setting.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true -class AiModerationSetting < ActiveRecord::Base - belongs_to :llm_model - belongs_to :ai_persona - - validates :llm_model_id, presence: true - validates :setting_type, presence: true - validates :setting_type, uniqueness: true - - def self.spam - find_by(setting_type: :spam) - end - - def custom_instructions - data["custom_instructions"] - end -end - -# == Schema Information -# -# Table name: ai_moderation_settings -# -# id :bigint not null, primary key -# setting_type :enum not null -# data :jsonb -# llm_model_id :bigint not null -# created_at :datetime not null -# updated_at :datetime not null -# ai_persona_id :bigint default(-31), not null -# -# Indexes -# -# index_ai_moderation_settings_on_setting_type (setting_type) UNIQUE -# diff --git a/app/models/ai_persona.rb b/app/models/ai_persona.rb deleted file mode 100644 index 4c40b5c8..00000000 --- a/app/models/ai_persona.rb +++ /dev/null @@ -1,432 +0,0 @@ -# frozen_string_literal: true - -class AiPersona < ActiveRecord::Base - # TODO remove this line 01-10-2025 - self.ignored_columns = %i[default_llm question_consolidator_llm] - - # places a hard limit, so per site we cache a maximum of 500 classes - MAX_PERSONAS_PER_SITE = 500 - - validates :name, presence: true, uniqueness: true, length: { maximum: 100 } - validates :description, presence: true, length: { maximum: 2000 } - validates :system_prompt, presence: true, length: { maximum: 10_000_000 } - validate :system_persona_unchangeable, on: :update, if: :system - validate :chat_preconditions - validate :allowed_seeded_model, if: :default_llm_id - validate :well_formated_examples - validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true - # leaves some room for growth but sets a maximum to avoid memory issues - # we may want to revisit this in the future - validates :vision_max_pixels, numericality: { greater_than: 0, maximum: 4_000_000 } - - validates :rag_chunk_tokens, numericality: { greater_than: 0, maximum: 50_000 } - validates :rag_chunk_overlap_tokens, numericality: { greater_than: -1, maximum: 200 } - validates :rag_conversation_chunks, numericality: { greater_than: 0, maximum: 1000 } - validates :forced_tool_count, numericality: { greater_than: -2, maximum: 100_000 } - - validate :tools_can_not_be_duplicated - - has_many :rag_document_fragments, dependent: :destroy, as: :target - - belongs_to :created_by, class_name: "User" - belongs_to :user - - belongs_to :default_llm, class_name: "LlmModel" - belongs_to :question_consolidator_llm, class_name: "LlmModel" - belongs_to :rag_llm_model, class_name: "LlmModel" - - has_many :upload_references, as: :target, dependent: :destroy - has_many :uploads, through: :upload_references - - before_destroy :ensure_not_system - before_update :regenerate_rag_fragments - - def self.persona_cache - @persona_cache ||= ::DiscourseAi::MultisiteHash.new("persona_cache") - end - - scope :ordered, -> { order("priority DESC, lower(name) ASC") } - - def self.all_personas(enabled_only: true) - persona_cache[:value] ||= AiPersona - .ordered - .all - .limit(MAX_PERSONAS_PER_SITE) - .map(&:class_instance) - - if enabled_only - persona_cache[:value].select { |p| p.enabled } - else - persona_cache[:value] - end - end - - def self.persona_users(user: nil) - persona_users = - persona_cache[:persona_users] ||= AiPersona - .where(enabled: true) - .joins(:user) - .map do |persona| - { - id: persona.id, - user_id: persona.user_id, - username: persona.user.username_lower, - allowed_group_ids: persona.allowed_group_ids, - default_llm_id: persona.default_llm_id, - force_default_llm: persona.force_default_llm, - allow_chat_channel_mentions: persona.allow_chat_channel_mentions, - allow_chat_direct_messages: persona.allow_chat_direct_messages, - allow_topic_mentions: persona.allow_topic_mentions, - allow_personal_messages: persona.allow_personal_messages, - } - end - - if user - persona_users.select { |persona_user| user.in_any_groups?(persona_user[:allowed_group_ids]) } - else - persona_users - end - end - - def self.allowed_modalities( - user: nil, - allow_chat_channel_mentions: false, - allow_chat_direct_messages: false, - allow_topic_mentions: false, - allow_personal_messages: false - ) - index = - "modality-#{allow_chat_channel_mentions}-#{allow_chat_direct_messages}-#{allow_topic_mentions}-#{allow_personal_messages}" - - personas = - persona_cache[index.to_sym] ||= persona_users.select do |persona| - next true if allow_chat_channel_mentions && persona[:allow_chat_channel_mentions] - next true if allow_chat_direct_messages && persona[:allow_chat_direct_messages] - next true if allow_topic_mentions && persona[:allow_topic_mentions] - next true if allow_personal_messages && persona[:allow_personal_messages] - false - end - - if user - personas.select { |u| user.in_any_groups?(u[:allowed_group_ids]) } - else - personas - end - end - - after_commit :bump_cache - - def bump_cache - self.class.persona_cache.flush! - end - - def tools_can_not_be_duplicated - return unless tools.is_a?(Array) - - seen_tools = Set.new - - custom_tool_ids = Set.new - builtin_tool_names = Set.new - - tools.each do |tool| - inner_name, _, _ = tool.is_a?(Array) ? tool : [tool, nil] - - if inner_name.start_with?("custom-") - custom_tool_ids.add(inner_name.split("-", 2).last.to_i) - else - builtin_tool_names.add(inner_name.downcase) - end - - if seen_tools.include?(inner_name) - errors.add(:tools, I18n.t("discourse_ai.ai_bot.personas.cannot_have_duplicate_tools")) - break - else - seen_tools.add(inner_name) - end - end - - return if errors.any? - - # Checking if there are any duplicate tool_names between custom and builtin tools - if builtin_tool_names.present? && custom_tool_ids.present? - AiTool - .where(id: custom_tool_ids) - .pluck(:tool_name) - .each do |tool_name| - if builtin_tool_names.include?(tool_name.downcase) - errors.add(:tools, I18n.t("discourse_ai.ai_bot.personas.cannot_have_duplicate_tools")) - break - end - end - end - end - - def class_instance - attributes = %i[ - id - user_id - system - mentionable - default_llm_id - max_context_posts - vision_enabled - vision_max_pixels - rag_conversation_chunks - question_consolidator_llm_id - allow_chat_channel_mentions - allow_chat_direct_messages - allow_topic_mentions - allow_personal_messages - force_default_llm - name - description - allowed_group_ids - tool_details - enabled - ] - - instance_attributes = {} - attributes.each do |attr| - value = self.read_attribute(attr) - instance_attributes[attr] = value - end - - instance_attributes[:username] = user&.username_lower - - options = {} - force_tool_use = [] - - tools = - self.tools.filter_map do |element| - klass = nil - - element = [element] if element.is_a?(String) - - inner_name, current_options, should_force_tool_use = - element.is_a?(Array) ? element : [element, nil] - - if inner_name.start_with?("custom-") - custom_tool_id = inner_name.split("-", 2).last.to_i - if AiTool.exists?(id: custom_tool_id, enabled: true) - klass = DiscourseAi::Personas::Tools::Custom.class_instance(custom_tool_id) - end - else - inner_name = inner_name.gsub("Tool", "") - inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name) - - begin - klass = "DiscourseAi::Personas::Tools::#{inner_name}".constantize - options[klass] = current_options if current_options - rescue StandardError - end - end - - force_tool_use << klass if should_force_tool_use - klass - end - - persona_class = DiscourseAi::Personas::Persona.system_personas_by_id[self.id] - if persona_class - return( - # we need a new copy so we don't leak information - # across sites - Class.new(persona_class) do - # required for localization - define_singleton_method(:to_s) { persona_class.to_s } - instance_attributes.each do |key, value| - # description/name are localized - define_singleton_method(key) { value } if key != :description && key != :name - end - define_method(:options) { options } - end - ) - end - - ai_persona_id = self.id - - Class.new(DiscourseAi::Personas::Persona) do - instance_attributes.each { |key, value| define_singleton_method(key) { value } } - - define_singleton_method(:to_s) do - "#<#{self.class.name} @name=#{name} @allowed_group_ids=#{allowed_group_ids.join(",")}>" - end - - define_singleton_method(:inspect) { to_s } - - define_method(:initialize) do |*args, **kwargs| - @ai_persona = AiPersona.find_by(id: ai_persona_id) - super(*args, **kwargs) - end - - define_method(:tools) { tools } - define_method(:force_tool_use) { force_tool_use } - define_method(:forced_tool_count) { @ai_persona&.forced_tool_count } - define_method(:options) { options } - define_method(:temperature) { @ai_persona&.temperature } - define_method(:top_p) { @ai_persona&.top_p } - define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." } - define_method(:uploads) { @ai_persona&.uploads } - define_method(:response_format) { @ai_persona&.response_format } - define_method(:examples) { @ai_persona&.examples } - end - end - - FIRST_PERSONA_USER_ID = -1200 - - def create_user! - raise "User already exists" if user_id && User.exists?(user_id) - - # find the first id smaller than FIRST_USER_ID that is not taken - id = nil - - id = DB.query_single(<<~SQL, FIRST_PERSONA_USER_ID, FIRST_PERSONA_USER_ID - 200).first - WITH seq AS ( - SELECT generate_series(?, ?, -1) AS id - ) - SELECT seq.id FROM seq - LEFT JOIN users ON users.id = seq.id - WHERE users.id IS NULL - ORDER BY seq.id DESC - SQL - - id = DB.query_single(<<~SQL).first if id.nil? - SELECT min(id) - 1 FROM users - SQL - - # note .invalid is a reserved TLD which will route nowhere - user = - User.new( - email: "#{SecureRandom.hex}@does-not-exist.invalid", - name: name.titleize, - username: UserNameSuggester.suggest(name + "_bot"), - active: true, - approved: true, - trust_level: TrustLevel[4], - id: id, - ) - user.save!(validate: false) - - update!(user_id: user.id) - user - end - - def regenerate_rag_fragments - if rag_chunk_tokens_changed? || rag_chunk_overlap_tokens_changed? - RagDocumentFragment.where(target: self).delete_all - end - end - - def features - DiscourseAi::Configuration::Feature.find_features_using(persona_id: id) - end - - private - - def chat_preconditions - if ( - allow_chat_channel_mentions || allow_chat_direct_messages || allow_topic_mentions || - force_default_llm - ) && !default_llm_id - errors.add(:default_llm, I18n.t("discourse_ai.ai_bot.personas.default_llm_required")) - end - end - - def system_persona_unchangeable - error_msg = I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona") - - if top_p_changed? || temperature_changed? || system_prompt_changed? || name_changed? || - description_changed? - errors.add(:base, error_msg) - elsif tools_changed? - old_tools = tools_change[0] - new_tools = tools_change[1] - - old_tool_names = old_tools.map { |t| t.is_a?(Array) ? t[0] : t }.to_set - new_tool_names = new_tools.map { |t| t.is_a?(Array) ? t[0] : t }.to_set - - errors.add(:base, error_msg) if old_tool_names != new_tool_names - elsif response_format_changed? - old_format = response_format_change[0].map { |f| f["key"] }.to_set - new_format = response_format_change[1].map { |f| f["key"] }.to_set - - errors.add(:base, error_msg) if old_format != new_format - elsif examples_changed? - old_examples = examples_change[0].flatten.to_set - new_examples = examples_change[1].flatten.to_set - - errors.add(:base, error_msg) if old_examples != new_examples - end - end - - def ensure_not_system - if system - errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_delete_system_persona")) - throw :abort - end - end - - def allowed_seeded_model - return if default_llm_id.blank? - - return if default_llm.nil? - return if !default_llm.seeded? - - return if SiteSetting.ai_bot_allowed_seeded_models_map.include?(default_llm.id.to_s) - - errors.add(:default_llm, I18n.t("discourse_ai.llm.configuration.invalid_seeded_model")) - end - - def well_formated_examples - return if examples.blank? - - if examples.is_a?(Array) && - examples.all? { |e| e.is_a?(Array) && e.length == 2 && e.all?(&:present?) } - return - end - - errors.add(:examples, I18n.t("discourse_ai.personas.malformed_examples")) - end -end - -# == Schema Information -# -# Table name: ai_personas -# -# id :bigint not null, primary key -# name :string(100) not null -# description :string(2000) not null -# system_prompt :string(10000000) not null -# allowed_group_ids :integer default([]), not null, is an Array -# created_by_id :integer -# enabled :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null -# system :boolean default(FALSE), not null -# priority :boolean default(FALSE), not null -# temperature :float -# top_p :float -# user_id :integer -# max_context_posts :integer -# vision_enabled :boolean default(FALSE), not null -# vision_max_pixels :integer default(1048576), not null -# rag_chunk_tokens :integer default(374), not null -# rag_chunk_overlap_tokens :integer default(10), not null -# rag_conversation_chunks :integer default(10), not null -# tool_details :boolean default(TRUE), not null -# tools :json not null -# forced_tool_count :integer default(-1), not null -# allow_chat_channel_mentions :boolean default(FALSE), not null -# allow_chat_direct_messages :boolean default(FALSE), not null -# allow_topic_mentions :boolean default(FALSE), not null -# allow_personal_messages :boolean default(TRUE), not null -# force_default_llm :boolean default(FALSE), not null -# rag_llm_model_id :bigint -# default_llm_id :bigint -# question_consolidator_llm_id :bigint -# response_format :jsonb -# examples :jsonb -# -# Indexes -# -# index_ai_personas_on_name (name) UNIQUE -# diff --git a/app/models/ai_spam_log.rb b/app/models/ai_spam_log.rb deleted file mode 100644 index 242e4b29..00000000 --- a/app/models/ai_spam_log.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true -class AiSpamLog < ActiveRecord::Base - belongs_to :post - belongs_to :llm_model - belongs_to :ai_api_audit_log - belongs_to :reviewable -end - -# == Schema Information -# -# Table name: ai_spam_logs -# -# id :bigint not null, primary key -# post_id :bigint not null -# llm_model_id :bigint not null -# ai_api_audit_log_id :bigint -# reviewable_id :bigint -# is_spam :boolean not null -# payload :string(20000) default(""), not null -# created_at :datetime not null -# updated_at :datetime not null -# error :string(3000) -# -# Indexes -# -# index_ai_spam_logs_on_post_id (post_id) -# diff --git a/app/models/ai_summary.rb b/app/models/ai_summary.rb deleted file mode 100644 index f0aa5575..00000000 --- a/app/models/ai_summary.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -class AiSummary < ActiveRecord::Base - # TODO remove this line 01-3-2025 - self.ignored_columns = %i[content_range] - - belongs_to :target, polymorphic: true - - enum :summary_type, { complete: 0, gist: 1 } - enum :origin, { human: 0, system: 1 } - - def self.store!(strategy, llm_model, summary, og_content, human:) - content_ids = og_content.map { |c| c[:id] } - - AiSummary - .upsert( - { - target_id: strategy.target.id, - target_type: strategy.target.class.name, - algorithm: llm_model.name, - highest_target_number: strategy.highest_target_number, - summarized_text: summary, - original_content_sha: build_sha(content_ids.join), - summary_type: strategy.type, - origin: !!human ? origins[:human] : origins[:system], - }, - unique_by: %i[target_id target_type summary_type], - update_only: %i[ - summarized_text - original_content_sha - algorithm - origin - highest_target_number - ], - ) - .first - .then { AiSummary.find_by(id: _1["id"]) } - end - - def self.build_sha(joined_ids) - Digest::SHA256.hexdigest(joined_ids) - end - - def mark_as_outdated - @outdated = true - end - - def outdated - @outdated || false - end -end - -# == Schema Information -# -# Table name: ai_summaries -# -# id :bigint not null, primary key -# target_id :integer not null -# target_type :string not null -# summarized_text :string not null -# original_content_sha :string not null -# algorithm :string not null -# created_at :datetime not null -# updated_at :datetime not null -# summary_type :integer default("complete"), not null -# origin :integer -# highest_target_number :integer default(1), not null -# -# Indexes -# -# idx_on_target_id_target_type_summary_type_3355609fbb (target_id,target_type,summary_type) UNIQUE -# index_ai_summaries_on_target_type_and_target_id (target_type,target_id) -# diff --git a/app/models/ai_tool.rb b/app/models/ai_tool.rb deleted file mode 100644 index 27fbd9c6..00000000 --- a/app/models/ai_tool.rb +++ /dev/null @@ -1,482 +0,0 @@ -# frozen_string_literal: true - -class AiTool < ActiveRecord::Base - validates :name, presence: true, length: { maximum: 100 }, uniqueness: true - validates :tool_name, presence: true, length: { maximum: 100 } - validates :description, presence: true, length: { maximum: 1000 } - validates :summary, presence: true, length: { maximum: 255 } - validates :script, presence: true, length: { maximum: 100_000 } - validates :created_by_id, presence: true - belongs_to :created_by, class_name: "User" - belongs_to :rag_llm_model, class_name: "LlmModel" - has_many :rag_document_fragments, dependent: :destroy, as: :target - has_many :upload_references, as: :target, dependent: :destroy - has_many :uploads, through: :upload_references - before_update :regenerate_rag_fragments - - ALPHANUMERIC_PATTERN = /\A[a-zA-Z0-9_]+\z/ - - validates :tool_name, - format: { - with: ALPHANUMERIC_PATTERN, - message: I18n.t("discourse_ai.tools.name.characters"), - } - - validate :validate_parameters_enum - - def signature - { - name: function_call_name, - description: description, - parameters: parameters.map(&:symbolize_keys), - } - end - - # Backwards compatibility: if tool_name is not set (existing custom tools), use name - def function_call_name - tool_name.presence || name - end - - def runner(parameters, llm:, bot_user:, context: nil) - DiscourseAi::Personas::ToolRunner.new( - parameters: parameters, - llm: llm, - bot_user: bot_user, - context: context, - tool: self, - ) - end - - after_commit :bump_persona_cache - - def bump_persona_cache - AiPersona.persona_cache.flush! - end - - def regenerate_rag_fragments - if rag_chunk_tokens_changed? || rag_chunk_overlap_tokens_changed? - RagDocumentFragment.where(target: self).delete_all - end - end - - def validate_parameters_enum - return unless parameters.is_a?(Array) - - parameters.each_with_index do |param, index| - next if !param.is_a?(Hash) || !param.key?("enum") - enum_values = param["enum"] - - if enum_values.empty? - errors.add( - :parameters, - "Parameter '#{param["name"]}' at index #{index}: enum cannot be empty", - ) - next - end - - if enum_values.uniq.length != enum_values.length - errors.add( - :parameters, - "Parameter '#{param["name"]}' at index #{index}: enum values must be unique", - ) - end - end - end - - def self.preamble - <<~JS - /** - * Tool API Quick Reference - * - * Entry Functions - * - * invoke(parameters): Main function. Receives parameters defined in the tool's signature (Object). - * Must return a JSON-serializable value (e.g., string, number, object, array). - * Example: - * function invoke(parameters) { return { result: "Data processed", input: parameters.query }; } - * - * details(): Optional function. Returns a string (can include basic HTML) describing - * the tool's action after invocation, often using data from the invocation. - * This is displayed in the chat interface. - * Example: - * let lastUrl; - * function invoke(parameters) { - * lastUrl = parameters.url; - * // ... perform action ... - * return { success: true, content: "..." }; - * } - * function details() { - * return `Browsed: ${lastUrl}`; - * } - * - * Provided Objects & Functions - * - * 1. http - * Performs HTTP requests. Max 20 requests per execution. - * - * http.get(url, options?): Performs GET request. - * Parameters: - * url (string): The request URL. - * options (Object, optional): - * headers (Object): Request headers (e.g., { "Authorization": "Bearer key" }). - * Returns: { status: number, body: string } - * - * http.post(url, options?): Performs POST request. - * Parameters: - * url (string): The request URL. - * options (Object, optional): - * headers (Object): Request headers. - * body (string | Object): Request body. If an object, it's stringified as JSON. - * Returns: { status: number, body: string } - * - * http.put(url, options?): Performs PUT request (similar to POST). - * http.patch(url, options?): Performs PATCH request (similar to POST). - * http.delete(url, options?): Performs DELETE request (similar to GET/POST). - * - * 2. llm - * Interacts with the Language Model. - * - * llm.truncate(text, length): Truncates text to a specified token length based on the configured LLM's tokenizer. - * Parameters: - * text (string): Text to truncate. - * length (number): Maximum number of tokens. - * Returns: string (truncated text) - * - * llm.generate(prompt): Generates text using the configured LLM associated with the tool runner. - * Parameters: - * prompt (string | Object): The prompt. Can be a simple string or an object - * like { messages: [{ type: "system", content: "..." }, { type: "user", content: "..." }] }. - * Returns: string (generated text) - * - * 3. index - * Searches attached RAG (Retrieval-Augmented Generation) documents linked to this tool. - * - * index.search(query, options?): Searches indexed document fragments. - * Parameters: - * query (string): The search query used for semantic search. - * options (Object, optional): - * filenames (Array): Filter search to fragments from specific uploaded filenames. - * limit (number): Maximum number of fragments to return (default: 10, max: 200). - * Returns: Array<{ fragment: string, metadata: string | null }> - Ordered by relevance. - * - * 4. upload - * Handles file uploads within Discourse. - * - * upload.create(filename, base_64_content): Uploads a file created by the tool, making it available in Discourse. - * Parameters: - * filename (string): The desired name for the file (basename is used for security). - * base_64_content (string): Base64 encoded content of the file. - * Returns: { id: number, url: string, short_url: string } - Details of the created upload record. - * - * upload.getUrl(shortUrl): Given a short URL, eg upload://12345, returns the full CDN friendly URL of the upload. - * 5. chain - * Controls the execution flow. - * - * chain.setCustomRaw(raw): Sets the final raw content of the bot's post and immediately - * stops the tool execution chain. Useful for tools that directly - * generate the full response content (e.g., image generation tools attaching the image markdown). - * Parameters: - * raw (string): The raw Markdown content for the post. - * Returns: void - * - * 6. discourse - * Interacts with Discourse specific features. Access is generally performed as the SystemUser. - * - * discourse.search(params): Performs a Discourse search. - * Parameters: - * params (Object): Search parameters (e.g., { search_query: "keyword", with_private: true, max_results: 10 }). - * `with_private: true` searches across all posts visible to the SystemUser. `result_style: 'detailed'` is used by default. - * Returns: Object (Discourse search results structure, includes posts, topics, users etc.) - * - * discourse.getPost(post_id): Retrieves details for a specific post. - * Parameters: - * post_id (number): The ID of the post. - * Returns: Object (Post details including `raw`, nested `topic` object with ListableTopicSerializer structure) or null if not found/accessible. - * - * discourse.getTopic(topic_id): Retrieves details for a specific topic. - * Parameters: - * topic_id (number): The ID of the topic. - * Returns: Object (Topic details using ListableTopicSerializer structure) or null if not found/accessible. - * - * discourse.getUser(user_id_or_username): Retrieves details for a specific user. - * Parameters: - * user_id_or_username (number | string): The ID or username of the user. - * Returns: Object (User details using UserSerializer structure) or null if not found. - * - * discourse.getPersona(name): Gets an object representing another AI Persona configured on the site. - * Parameters: - * name (string): The name of the target persona. - * Returns: Object { respondTo: function(params) } or null if persona not found. - * respondTo(params): Instructs the target persona to generate a response within the current context (e.g., replying to the same post or chat message). - * Parameters: - * params (Object, optional): { instructions: string, whisper: boolean } - * Returns: { success: boolean, post_id?: number, post_number?: number, message_id?: number } or { error: string } - * - * discourse.createChatMessage(params): Creates a new message in a Discourse Chat channel. - * Parameters: - * params (Object): { channel_name: string, username: string, message: string } - * `channel_name` can be the channel name or slug. - * `username` specifies the user who should appear as the sender. The user must exist. - * The sending user must have permission to post in the channel. - * Returns: { success: boolean, message_id?: number, message?: string, created_at?: string } or { error: string } - * - * 7. context - * An object containing information about the environment where the tool is being run. - * Available properties depend on the invocation context, but may include: - * post_id (number): ID of the post triggering the tool (if in a Post context). - * topic_id (number): ID of the topic (if in a Post context). - * private_message (boolean): Whether the context is a private message (in Post context). - * message_id (number): ID of the chat message triggering the tool (if in Chat context). - * channel_id (number): ID of the chat channel (if in Chat context). - * user (Object): Details of the user invoking the tool/persona (structure may vary, often null or SystemUser details unless explicitly passed). - * participants (string): Comma-separated list of usernames in a PM (if applicable). - * // ... other potential context-specific properties added by the calling environment. - * - * Constraints - * - * Execution Time: ≤ 2000ms (default timeout in milliseconds) - This timer *pauses* during external HTTP requests or LLM calls initiated via `http.*` or `llm.generate`, but applies to the script's own processing time. - * Memory: ≤ 10MB (V8 heap limit) - * Stack Depth: ≤ 20 (Marshal stack depth limit for Ruby interop) - * HTTP Requests: ≤ 20 per execution - * Exceeding limits will result in errors or termination (e.g., timeout error, out-of-memory error, TooManyRequestsError). - * - * Security - * - * Sandboxed Environment: The script runs in a restricted V8 JavaScript environment (via MiniRacer). - * No direct access to browser or environment, browser globals (like `window` or `document`), or the host system's file system. - * Network requests are proxied through the Discourse backend, not made directly from the sandbox. - */ - JS - end - - def self.presets - [ - { - preset_id: "browse_web_jina", - name: "Browse Web", - tool_name: "browse_web", - description: "Browse the web as a markdown document", - parameters: [ - { name: "url", type: "string", required: true, description: "The URL to browse" }, - ], - script: <<~SCRIPT, - #{preamble} - let url; - function invoke(p) { - url = p.url; - result = http.get(`https://r.jina.ai/${url}`); - // truncates to 15000 tokens - return llm.truncate(result.body, 15000); - } - function details() { - return "Read: " + url - } - SCRIPT - }, - { - preset_id: "exchange_rate", - name: "Exchange Rate", - tool_name: "exchange_rate", - description: "Get current exchange rates for various currencies", - parameters: [ - { - name: "base_currency", - type: "string", - required: true, - description: "The base currency code (e.g., USD, EUR)", - }, - { - name: "target_currency", - type: "string", - required: true, - description: "The target currency code (e.g., EUR, JPY)", - }, - { name: "amount", type: "number", description: "Amount to convert eg: 123.45" }, - ], - script: <<~SCRIPT, - #{preamble} - // note: this script uses the open.er-api.com service, it is only updated - // once every 24 hours, for more up to date rates see: https://www.exchangerate-api.com - function invoke(params) { - const url = `https://open.er-api.com/v6/latest/${params.base_currency}`; - const result = http.get(url); - if (result.status !== 200) { - return { error: "Failed to fetch exchange rates" }; - } - const data = JSON.parse(result.body); - const rate = data.rates[params.target_currency]; - if (!rate) { - return { error: "Target currency not found" }; - } - - const rval = { - base_currency: params.base_currency, - target_currency: params.target_currency, - exchange_rate: rate, - last_updated: data.time_last_update_utc - }; - - if (params.amount) { - rval.original_amount = params.amount; - rval.converted_amount = params.amount * rate; - } - - return rval; - } - - function details() { - return "Rates By Exchange Rate API"; - } - SCRIPT - summary: "Get current exchange rates between two currencies", - }, - { - preset_id: "stock_quote", - name: "Stock Quote (AlphaVantage)", - tool_name: "stock_quote", - description: "Get real-time stock quote information using AlphaVantage API", - parameters: [ - { - name: "symbol", - type: "string", - required: true, - description: "The stock symbol (e.g., AAPL, GOOGL)", - }, - ], - script: <<~SCRIPT, - #{preamble} - function invoke(params) { - const apiKey = 'YOUR_ALPHAVANTAGE_API_KEY'; // Replace with your actual API key - const url = `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${params.symbol}&apikey=${apiKey}`; - - const result = http.get(url); - if (result.status !== 200) { - return { error: "Failed to fetch stock quote" }; - } - - const data = JSON.parse(result.body); - if (data['Error Message']) { - return { error: data['Error Message'] }; - } - - const quote = data['Global Quote']; - if (!quote || Object.keys(quote).length === 0) { - return { error: "No data found for the given symbol" }; - } - - return { - symbol: quote['01. symbol'], - price: parseFloat(quote['05. price']), - change: parseFloat(quote['09. change']), - change_percent: quote['10. change percent'], - volume: parseInt(quote['06. volume']), - latest_trading_day: quote['07. latest trading day'] - }; - } - - function details() { - return "Stock data provided by AlphaVantage"; - } - SCRIPT - summary: "Get real-time stock quotes using AlphaVantage API", - }, - { - preset_id: "image_generation", - name: "Image Generation (Flux)", - tool_name: "image_generation", - description: - "Generate images using the FLUX model from Black Forest Labs using together.ai", - parameters: [ - { - name: "prompt", - type: "string", - required: true, - description: "The text prompt for image generation", - }, - { - name: "seed", - type: "number", - required: false, - description: "Optional seed for random number generation", - }, - ], - script: <<~SCRIPT, - #{preamble} - const apiKey = "YOUR_KEY"; - const model = "black-forest-labs/FLUX.1.1-pro"; - - function invoke(params) { - let seed = parseInt(params.seed); - if (!(seed > 0)) { - seed = Math.floor(Math.random() * 1000000) + 1; - } - - const prompt = params.prompt; - const body = { - model: model, - prompt: prompt, - width: 1024, - height: 768, - steps: 10, - n: 1, - seed: seed, - response_format: "b64_json", - }; - - const result = http.post("https://api.together.xyz/v1/images/generations", { - headers: { - "Authorization": `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - const base64Image = JSON.parse(result.body).data[0].b64_json; - const image = upload.create("generated_image.png", base64Image); - const raw = `\n![${prompt}](${image.short_url})\n`; - chain.setCustomRaw(raw); - - return { result: "Image generated successfully", seed: seed }; - } - - function details() { - return "Generates images based on a text prompt using the FLUX model."; - } - SCRIPT - summary: "Generate image", - }, - { preset_id: "empty_tool", script: <<~SCRIPT }, - #{preamble} - function invoke(params) { - // logic here - return params; - } - function details() { - return "Details about this tool"; - } - SCRIPT - ].map do |preset| - preset[:preset_name] = I18n.t("discourse_ai.tools.presets.#{preset[:preset_id]}.name") - preset - end - end -end - -# == Schema Information -# -# Table name: ai_tools -# -# id :bigint not null, primary key -# name :string not null -# description :string not null -# summary :string not null -# parameters :jsonb not null -# script :text not null -# created_by_id :integer not null -# enabled :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null -# rag_chunk_tokens :integer default(374), not null -# rag_chunk_overlap_tokens :integer default(10), not null -# tool_name :string(100) default(""), not null -# rag_llm_model_id :bigint diff --git a/app/models/chat_message_custom_prompt.rb b/app/models/chat_message_custom_prompt.rb deleted file mode 100644 index 6b36b24b..00000000 --- a/app/models/chat_message_custom_prompt.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageCustomPrompt < ActiveRecord::Base - # belongs_to chat message but going to avoid the cross dependency for now -end - -# == Schema Information -# -# Table name: chat_message_custom_prompts -# -# id :bigint not null, primary key -# message_id :bigint not null -# custom_prompt :json not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_chat_message_custom_prompts_on_message_id (message_id) UNIQUE -# diff --git a/app/models/classification_result.rb b/app/models/classification_result.rb deleted file mode 100644 index 1d3f7b12..00000000 --- a/app/models/classification_result.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class ClassificationResult < ActiveRecord::Base - belongs_to :target, polymorphic: true - - def self.has_sentiment_classification? - where(classification_type: "sentiment").exists? - end -end - -# == Schema Information -# -# Table name: classification_results -# -# id :bigint not null, primary key -# model_used :string -# classification_type :string -# target_id :bigint -# target_type :string -# classification :jsonb -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# unique_classification_target_per_type (target_id,target_type,model_used) UNIQUE -# diff --git a/app/models/completion_prompt.rb b/app/models/completion_prompt.rb deleted file mode 100644 index bec27754..00000000 --- a/app/models/completion_prompt.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -class CompletionPrompt < ActiveRecord::Base - # - # DEPRECATED. - # TODO(roman): Remove after 06-17-25 - # - - TRANSLATE = -301 - GENERATE_TITLES = -307 - PROOFREAD = -303 - MARKDOWN_TABLE = -304 - CUSTOM_PROMPT = -305 - EXPLAIN = -306 - ILLUSTRATE_POST = -308 - DETECT_TEXT_LOCALE = -309 - - enum :prompt_type, { text: 0, list: 1, diff: 2 } - - validates :messages, length: { maximum: 20 } - validate :each_message_length - - after_commit { DiscourseAi::AiHelper::Assistant.clear_prompt_cache! } - - def self.enabled_by_name(name) - where(enabled: true).find_by(name: name) - end - - attr_accessor :custom_instruction - - def messages_with_input(input) - return unless input - - user_input = - if id == CUSTOM_PROMPT && custom_instruction.present? - "#{custom_instruction}:\n#{input}" - else - input - end - - instructions = [messages_hash[:insts], messages_hash[:post_insts].to_s].join("\n") - - prompt = DiscourseAi::Completions::Prompt.new(instructions) - - messages_hash[:examples].to_a.each do |example_pair| - prompt.push(type: :user, content: example_pair.first) - prompt.push(type: :model, content: example_pair.second) - end - - prompt.push(type: :user, content: "#{user_input}") - - prompt - end - - private - - def messages_hash - @messages_hash ||= messages.symbolize_keys! - end - - def each_message_length - messages.each_with_index do |msg, idx| - next if msg["content"].length <= 1000 - - errors.add(:messages, I18n.t("discourse_ai.errors.prompt_message_length", idx: idx + 1)) - end - end -end - -# == Schema Information -# -# Table name: completion_prompts -# -# id :bigint not null, primary key -# name :string not null -# translated_name :string -# prompt_type :integer default("text"), not null -# enabled :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null -# messages :jsonb -# temperature :integer -# stop_sequences :string is an Array -# -# Indexes -# -# index_completion_prompts_on_name (name) -# diff --git a/app/models/embedding_definition.rb b/app/models/embedding_definition.rb deleted file mode 100644 index 92caabee..00000000 --- a/app/models/embedding_definition.rb +++ /dev/null @@ -1,244 +0,0 @@ -# frozen_string_literal: true - -class EmbeddingDefinition < ActiveRecord::Base - CLOUDFLARE = "cloudflare" - HUGGING_FACE = "hugging_face" - OPEN_AI = "open_ai" - GOOGLE = "google" - - class << self - def provider_names - [CLOUDFLARE, HUGGING_FACE, OPEN_AI, GOOGLE] - end - - def distance_functions - %w[<#> <=>] - end - - def tokenizer_names - [ - DiscourseAi::Tokenizer::AllMpnetBaseV2Tokenizer, - DiscourseAi::Tokenizer::BgeLargeEnTokenizer, - DiscourseAi::Tokenizer::BgeM3Tokenizer, - DiscourseAi::Tokenizer::GeminiTokenizer, - DiscourseAi::Tokenizer::MultilingualE5LargeTokenizer, - DiscourseAi::Tokenizer::OpenAiTokenizer, - DiscourseAi::Tokenizer::OpenAiCl100kTokenizer, - DiscourseAi::Tokenizer::MistralTokenizer, - DiscourseAi::Tokenizer::QwenTokenizer, - ].map(&:name) - end - - def provider_params - { open_ai: { model_name: :text } } - end - - def presets - @presets ||= - begin - [ - { - preset_id: "bge-large-en", - display_name: "bge-large-en", - dimensions: 1024, - max_sequence_length: 512, - pg_function: "<#>", - tokenizer_class: "DiscourseAi::Tokenizer::BgeLargeEnTokenizer", - provider: HUGGING_FACE, - search_prompt: "Represent this sentence for searching relevant passages:", - }, - { - preset_id: "bge-m3", - display_name: "bge-m3", - dimensions: 1024, - max_sequence_length: 8192, - pg_function: "<#>", - tokenizer_class: "DiscourseAi::Tokenizer::BgeM3Tokenizer", - provider: HUGGING_FACE, - }, - { - preset_id: "gemini-embedding-001", - display_name: "Gemini's embedding-001", - dimensions: 768, - max_sequence_length: 1536, - pg_function: "<=>", - url: - "https://generativelanguage.googleapis.com/v1beta/models/embedding-001:embedContent", - tokenizer_class: "DiscourseAi::Tokenizer::GeminiTokenizer", - provider: GOOGLE, - }, - { - preset_id: "multilingual-e5-large", - display_name: "multilingual-e5-large", - dimensions: 1024, - max_sequence_length: 512, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::MultilingualE5LargeTokenizer", - provider: HUGGING_FACE, - }, - # "text-embedding-3-large" real dimentions are 3072, but we only support up to 2000 in the - # indexes, so we downsample to 2000 via API. - { - preset_id: "text-embedding-3-large", - display_name: "text-embedding-3-large", - dimensions: 2000, - max_sequence_length: 8191, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiCl100kTokenizer", - url: "https://api.openai.com/v1/embeddings", - provider: OPEN_AI, - matryoshka_dimensions: true, - provider_params: { - model_name: "text-embedding-3-large", - }, - }, - { - preset_id: "text-embedding-3-small", - display_name: "text-embedding-3-small", - dimensions: 1536, - max_sequence_length: 8191, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiCl100kTokenizer", - url: "https://api.openai.com/v1/embeddings", - provider: OPEN_AI, - matryoshka_dimensions: true, - provider_params: { - model_name: "text-embedding-3-small", - }, - }, - { - preset_id: "text-embedding-ada-002", - display_name: "text-embedding-ada-002", - dimensions: 1536, - max_sequence_length: 8191, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiCl100kTokenizer", - url: "https://api.openai.com/v1/embeddings", - provider: OPEN_AI, - provider_params: { - model_name: "text-embedding-ada-002", - }, - }, - ] - end - end - end - - validates :provider, presence: true, inclusion: provider_names - validates :display_name, presence: true, length: { maximum: 100 } - validates :tokenizer_class, presence: true, inclusion: tokenizer_names - validates_presence_of :url, :api_key, :dimensions, :max_sequence_length, :pg_function - - after_create :create_indexes - - def create_indexes - Jobs.enqueue(:manage_embedding_def_search_index, id: self.id) - end - - def tokenizer - tokenizer_class.constantize - end - - def inference_client - case provider - when CLOUDFLARE - cloudflare_client - when HUGGING_FACE - hugging_face_client - when OPEN_AI - open_ai_client - when GOOGLE - gemini_client - else - raise "Uknown embeddings provider" - end - end - - def lookup_custom_param(key) - provider_params&.dig(key) - end - - def endpoint_url - return url if !url.starts_with?("srv://") - - service = DiscourseAi::Utils::DnsSrv.lookup(url.sub("srv://", "")) - "https://#{service.target}:#{service.port}" - end - - def prepare_query_text(text, asymetric: false) - strategy.prepare_query_text(text, self, asymetric: asymetric) - end - - def prepare_target_text(target) - strategy.prepare_target_text(target, self) - end - - def strategy_id - strategy.id - end - - def strategy_version - strategy.version - end - - def api_key - if seeded? - env_key = "DISCOURSE_AI_SEEDED_EMBEDDING_API_KEY" - ENV[env_key] || self[:api_key] - else - self[:api_key] - end - end - - private - - def strategy - @strategy ||= DiscourseAi::Embeddings::Strategies::Truncation.new - end - - def cloudflare_client - DiscourseAi::Inference::CloudflareWorkersAi.new(endpoint_url, api_key) - end - - def hugging_face_client - DiscourseAi::Inference::HuggingFaceTextEmbeddings.new(endpoint_url, api_key) - end - - def open_ai_client - client_dimensions = matryoshka_dimensions ? dimensions : nil - - DiscourseAi::Inference::OpenAiEmbeddings.new( - endpoint_url, - api_key, - lookup_custom_param("model_name"), - client_dimensions, - ) - end - - def gemini_client - DiscourseAi::Inference::GeminiEmbeddings.new(endpoint_url, api_key) - end -end - -# == Schema Information -# -# Table name: embedding_definitions -# -# id :bigint not null, primary key -# display_name :string not null -# dimensions :integer not null -# max_sequence_length :integer not null -# version :integer default(1), not null -# pg_function :string not null -# provider :string not null -# tokenizer_class :string not null -# url :string not null -# api_key :string -# seeded :boolean default(FALSE), not null -# provider_params :jsonb -# created_at :datetime not null -# updated_at :datetime not null -# embed_prompt :string default(""), not null -# search_prompt :string default(""), not null -# matryoshka_dimensions :boolean default(FALSE), not null -# diff --git a/app/models/inferred_concept.rb b/app/models/inferred_concept.rb deleted file mode 100644 index 73687878..00000000 --- a/app/models/inferred_concept.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class InferredConcept < ActiveRecord::Base - has_many :inferred_concept_topics - has_many :topics, through: :inferred_concept_topics - - has_many :inferred_concept_posts - has_many :posts, through: :inferred_concept_posts - - validates :name, presence: true, uniqueness: true -end - -# == Schema Information -# -# Table name: inferred_concepts -# -# id :bigint not null, primary key -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_inferred_concepts_on_name (name) UNIQUE -# diff --git a/app/models/inferred_concept_post.rb b/app/models/inferred_concept_post.rb deleted file mode 100644 index cf1d0077..00000000 --- a/app/models/inferred_concept_post.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class InferredConceptPost < ActiveRecord::Base - belongs_to :inferred_concept - belongs_to :post - - validates :inferred_concept_id, presence: true - validates :post_id, presence: true - validates :inferred_concept_id, uniqueness: { scope: :post_id } -end - -# == Schema Information -# -# Table name: inferred_concept_posts -# -# inferred_concept_id :bigint -# post_id :bigint -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_inferred_concept_posts_on_inferred_concept_id (inferred_concept_id) -# index_inferred_concept_posts_uniqueness (post_id,inferred_concept_id) UNIQUE -# diff --git a/app/models/inferred_concept_topic.rb b/app/models/inferred_concept_topic.rb deleted file mode 100644 index 3041f1cf..00000000 --- a/app/models/inferred_concept_topic.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class InferredConceptTopic < ActiveRecord::Base - belongs_to :inferred_concept - belongs_to :topic - - validates :inferred_concept_id, presence: true - validates :topic_id, presence: true - validates :inferred_concept_id, uniqueness: { scope: :topic_id } -end - -# == Schema Information -# -# Table name: inferred_concept_topics -# -# inferred_concept_id :bigint -# topic_id :bigint -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_inferred_concept_topics_on_inferred_concept_id (inferred_concept_id) -# index_inferred_concept_topics_uniqueness (topic_id,inferred_concept_id) UNIQUE -# diff --git a/app/models/llm_model.rb b/app/models/llm_model.rb deleted file mode 100644 index 937c5373..00000000 --- a/app/models/llm_model.rb +++ /dev/null @@ -1,203 +0,0 @@ -# frozen_string_literal: true - -class LlmModel < ActiveRecord::Base - FIRST_BOT_USER_ID = -1200 - BEDROCK_PROVIDER_NAME = "aws_bedrock" - - has_many :llm_quotas, dependent: :destroy - belongs_to :user - - validates :display_name, presence: true, length: { maximum: 100 } - validates :tokenizer, presence: true, inclusion: DiscourseAi::Completions::Llm.tokenizer_names - validates :provider, presence: true, inclusion: DiscourseAi::Completions::Llm.provider_names - validates :url, presence: true, unless: -> { provider == BEDROCK_PROVIDER_NAME } - validates_presence_of :name, :api_key - validates :max_prompt_tokens, numericality: { greater_than: 0 } - validates :input_cost, - :cached_input_cost, - :output_cost, - :max_output_tokens, - numericality: { - greater_than_or_equal_to: 0, - }, - allow_nil: true - validate :required_provider_params - scope :in_use, - -> do - model_ids = DiscourseAi::Configuration::LlmEnumerator.global_usage.keys - where(id: model_ids) - end - - def self.provider_params - { - aws_bedrock: { - access_key_id: :text, - region: :text, - disable_native_tools: :checkbox, - disable_temperature: :checkbox, - disable_top_p: :checkbox, - enable_reasoning: :checkbox, - reasoning_tokens: :number, - }, - anthropic: { - disable_native_tools: :checkbox, - disable_temperature: :checkbox, - disable_top_p: :checkbox, - enable_reasoning: :checkbox, - reasoning_tokens: :number, - }, - open_ai: { - organization: :text, - disable_native_tools: :checkbox, - disable_temperature: :checkbox, - disable_top_p: :checkbox, - disable_streaming: :checkbox, - enable_responses_api: :checkbox, - reasoning_effort: { - type: :enum, - values: %w[default low medium high], - default: "default", - }, - }, - mistral: { - disable_native_tools: :checkbox, - }, - google: { - disable_native_tools: :checkbox, - enable_thinking: :checkbox, - disable_temperature: :checkbox, - disable_top_p: :checkbox, - thinking_tokens: :number, - }, - azure: { - disable_native_tools: :checkbox, - }, - hugging_face: { - disable_system_prompt: :checkbox, - }, - vllm: { - disable_system_prompt: :checkbox, - }, - ollama: { - disable_system_prompt: :checkbox, - enable_native_tool: :checkbox, - disable_streaming: :checkbox, - }, - open_router: { - disable_native_tools: :checkbox, - provider_order: :text, - provider_quantizations: :text, - disable_streaming: :checkbox, - disable_temperature: :checkbox, - disable_top_p: :checkbox, - }, - } - end - - def to_llm - DiscourseAi::Completions::Llm.proxy(self) - end - - def identifier - "custom:#{id}" - end - - def toggle_companion_user - return if name == "fake" && Rails.env.production? - - enable_check = SiteSetting.ai_bot_enabled && enabled_chat_bot - - if enable_check - if !user - next_id = DB.query_single(<<~SQL).first - SELECT min(id) - 1 FROM users - SQL - - new_user = - User.new( - id: [FIRST_BOT_USER_ID, next_id].min, - email: "no_email_#{SecureRandom.hex}", - name: name.titleize, - username: UserNameSuggester.suggest(name), - active: true, - approved: true, - admin: true, - moderator: true, - trust_level: TrustLevel[4], - ) - new_user.save!(validate: false) - self.update!(user: new_user) - else - user.active = true - user.save!(validate: false) - end - elsif user - # will include deleted - has_posts = DB.query_single("SELECT 1 FROM posts WHERE user_id = #{user.id} LIMIT 1").present? - - if has_posts - user.update!(active: false) if user.active - else - user.destroy! - self.update!(user: nil) - end - end - end - - def tokenizer_class - tokenizer.constantize - end - - def lookup_custom_param(key) - provider_params&.dig(key) - end - - def seeded? - id.present? && id < 0 - end - - def api_key - if seeded? - env_key = "DISCOURSE_AI_SEEDED_LLM_API_KEY_#{id.abs}" - ENV[env_key] || self[:api_key] - else - self[:api_key] - end - end - - private - - def required_provider_params - return if provider != BEDROCK_PROVIDER_NAME - - %w[access_key_id region].each do |field| - if lookup_custom_param(field).blank? - errors.add(:base, I18n.t("discourse_ai.llm_models.missing_provider_param", param: field)) - end - end - end -end - -# == Schema Information -# -# Table name: llm_models -# -# id :bigint not null, primary key -# display_name :string -# name :string not null -# provider :string not null -# tokenizer :string not null -# max_prompt_tokens :integer not null -# created_at :datetime not null -# updated_at :datetime not null -# url :string -# api_key :string -# user_id :integer -# enabled_chat_bot :boolean default(FALSE), not null -# provider_params :jsonb -# vision_enabled :boolean default(FALSE), not null -# input_cost :float -# cached_input_cost :float -# output_cost :float -# max_output_tokens :integer -# diff --git a/app/models/llm_quota.rb b/app/models/llm_quota.rb deleted file mode 100644 index 2612895f..00000000 --- a/app/models/llm_quota.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -class LlmQuota < ActiveRecord::Base - self.table_name = "llm_quotas" - - belongs_to :group - belongs_to :llm_model - has_many :llm_quota_usages - - validates :group_id, presence: true - # we can not validate on create cause it breaks build - validates :llm_model_id, presence: true, on: :update - validates :duration_seconds, presence: true, numericality: { greater_than: 0 } - validates :max_tokens, numericality: { only_integer: true, greater_than: 0, allow_nil: true } - validates :max_usages, numericality: { greater_than: 0, allow_nil: true } - - validate :at_least_one_limit - - def self.check_quotas!(llm, user) - return true if user.blank? - quotas = joins(:group).where(llm_model: llm).where(group: user.groups) - - return true if quotas.empty? - errors = - quotas.map do |quota| - usage = LlmQuotaUsage.find_or_create_for(user: user, llm_quota: quota) - begin - usage.check_quota! - nil - rescue LlmQuotaUsage::QuotaExceededError => e - e - end - end - - return if errors.include?(nil) - - raise errors.first - end - - def self.log_usage(llm, user, input_tokens, output_tokens) - return if user.blank? - - quotas = joins(:group).where(llm_model: llm).where(group: user.groups) - - quotas.each do |quota| - usage = LlmQuotaUsage.find_or_create_for(user: user, llm_quota: quota) - usage.increment_usage!(input_tokens: input_tokens, output_tokens: output_tokens) - end - end - - def available_tokens - max_tokens - end - - def available_usages - max_usages - end - - private - - def at_least_one_limit - if max_tokens.nil? && max_usages.nil? - errors.add(:base, I18n.t("discourse_ai.errors.quota_required")) - end - end -end - -# == Schema Information -# -# Table name: llm_quotas -# -# id :bigint not null, primary key -# group_id :bigint not null -# llm_model_id :bigint not null -# max_tokens :integer -# max_usages :integer -# duration_seconds :integer not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_llm_quotas_on_group_id_and_llm_model_id (group_id,llm_model_id) UNIQUE -# index_llm_quotas_on_llm_model_id (llm_model_id) -# diff --git a/app/models/llm_quota_usage.rb b/app/models/llm_quota_usage.rb deleted file mode 100644 index 2cbf900d..00000000 --- a/app/models/llm_quota_usage.rb +++ /dev/null @@ -1,120 +0,0 @@ -# frozen_string_literal: true - -class LlmQuotaUsage < ActiveRecord::Base - self.table_name = "llm_quota_usages" - - QuotaExceededError = Class.new(StandardError) - - belongs_to :user - belongs_to :llm_quota - - validates :user_id, presence: true - validates :llm_quota_id, presence: true - validates :input_tokens_used, presence: true, numericality: { greater_than_or_equal_to: 0 } - validates :output_tokens_used, presence: true, numericality: { greater_than_or_equal_to: 0 } - validates :usages, presence: true, numericality: { greater_than_or_equal_to: 0 } - validates :started_at, presence: true - validates :reset_at, presence: true - - def self.find_or_create_for(user:, llm_quota:) - usage = find_or_initialize_by(user: user, llm_quota: llm_quota) - - if usage.new_record? - now = Time.current - usage.started_at = now - usage.reset_at = now + llm_quota.duration_seconds.seconds - usage.input_tokens_used = 0 - usage.output_tokens_used = 0 - usage.usages = 0 - usage.save! - end - - usage - end - - def reset_if_needed! - return if Time.current < reset_at - - now = Time.current - update!( - input_tokens_used: 0, - output_tokens_used: 0, - usages: 0, - started_at: now, - reset_at: now + llm_quota.duration_seconds.seconds, - ) - end - - def increment_usage!(input_tokens:, output_tokens:) - reset_if_needed! - - increment!(:usages) - increment!(:input_tokens_used, input_tokens) - increment!(:output_tokens_used, output_tokens) - end - - def check_quota! - reset_if_needed! - - if quota_exceeded? - raise QuotaExceededError.new( - I18n.t( - "discourse_ai.errors.quota_exceeded", - relative_time: AgeWords.distance_of_time_in_words(reset_at, Time.now), - ), - ) - end - end - - def quota_exceeded? - return false if !llm_quota - - (llm_quota.max_tokens.present? && total_tokens_used > llm_quota.max_tokens) || - (llm_quota.max_usages.present? && usages > llm_quota.max_usages) - end - - def total_tokens_used - input_tokens_used + output_tokens_used - end - - def remaining_tokens - return nil if llm_quota.max_tokens.nil? - [0, llm_quota.max_tokens - total_tokens_used].max - end - - def remaining_usages - return nil if llm_quota.max_usages.nil? - [0, llm_quota.max_usages - usages].max - end - - def percentage_tokens_used - return 0 if llm_quota.max_tokens.nil? || llm_quota.max_tokens.zero? - [(total_tokens_used.to_f / llm_quota.max_tokens * 100).round, 100].min - end - - def percentage_usages_used - return 0 if llm_quota.max_usages.nil? || llm_quota.max_usages.zero? - [(usages.to_f / llm_quota.max_usages * 100).round, 100].min - end -end - -# == Schema Information -# -# Table name: llm_quota_usages -# -# id :bigint not null, primary key -# user_id :bigint not null -# llm_quota_id :bigint not null -# input_tokens_used :integer not null -# output_tokens_used :integer not null -# usages :integer not null -# started_at :datetime not null -# reset_at :datetime not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_llm_quota_usages_on_llm_quota_id (llm_quota_id) -# index_llm_quota_usages_on_user_id_and_llm_quota_id (user_id,llm_quota_id) UNIQUE -# diff --git a/app/models/model_accuracy.rb b/app/models/model_accuracy.rb deleted file mode 100644 index 07d4b65b..00000000 --- a/app/models/model_accuracy.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -class ModelAccuracy < ActiveRecord::Base - def self.adjust_model_accuracy(new_status, reviewable) - return if %i[approved rejected].exclude?(new_status) - return if [ReviewableAiPost, ReviewableAiChatMessage].exclude?(reviewable.class) - - verdicts = reviewable.payload.to_h["verdicts"] || {} - - verdicts.each do |model_name, verdict| - accuracy_model = find_by(model: model_name) - - attribute = - if verdict - new_status == :approved ? :flags_agreed : :flags_disagreed - else - new_status == :rejected ? :flags_agreed : :flags_disagreed - end - - accuracy_model.increment!(attribute) - end - end - - def calculate_accuracy - return 0 if total_flags.zero? - - (flags_agreed * 100) / total_flags - end - - private - - def total_flags - flags_agreed + flags_disagreed - end -end - -# == Schema Information -# -# Table name: model_accuracies -# -# id :bigint not null, primary key -# model :string not null -# classification_type :string not null -# flags_agreed :integer default(0), not null -# flags_disagreed :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_model_accuracies_on_model (model) UNIQUE -# diff --git a/app/models/post_custom_prompt.rb b/app/models/post_custom_prompt.rb deleted file mode 100644 index da35eac8..00000000 --- a/app/models/post_custom_prompt.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class PostCustomPrompt < ActiveRecord::Base - belongs_to :post -end - -class ::Post - has_one :post_custom_prompt, dependent: :destroy -end - -# == Schema Information -# -# Table name: post_custom_prompts -# -# id :bigint not null, primary key -# post_id :integer not null -# custom_prompt :json not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_post_custom_prompts_on_post_id (post_id) UNIQUE -# diff --git a/app/models/rag_document_fragment.rb b/app/models/rag_document_fragment.rb deleted file mode 100644 index b546b164..00000000 --- a/app/models/rag_document_fragment.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -class RagDocumentFragment < ActiveRecord::Base - # TODO Jan 2025 - remove - self.ignored_columns = %i[ai_persona_id] - - belongs_to :upload - belongs_to :target, polymorphic: true - - class << self - def link_target_and_uploads(target, upload_ids) - return if target.blank? - return if upload_ids.blank? - return if !DiscourseAi::Embeddings.enabled? - - UploadReference.ensure_exist!(upload_ids: upload_ids, target: target) - - upload_ids.each do |upload_id| - Jobs.enqueue( - :digest_rag_upload, - target_id: target.id, - target_type: target.class.to_s, - upload_id: upload_id, - ) - end - end - - def update_target_uploads(target, upload_ids) - return if target.blank? - return if !DiscourseAi::Embeddings.enabled? - - if upload_ids.blank? - RagDocumentFragment.where(target: target).destroy_all - UploadReference.where(target: target).destroy_all - else - RagDocumentFragment.where(target: target).where.not(upload_id: upload_ids).destroy_all - link_target_and_uploads(target, upload_ids) - end - end - - def indexing_status(persona, uploads) - embeddings_table = DiscourseAi::Embeddings::Schema.for(self).table - - results = - DB.query( - <<~SQL, - SELECT - uploads.id, - SUM(CASE WHEN (rdf.upload_id IS NOT NULL) THEN 1 ELSE 0 END) AS total, - SUM(CASE WHEN (eft.rag_document_fragment_id IS NOT NULL) THEN 1 ELSE 0 END) as indexed, - SUM(CASE WHEN (rdf.upload_id IS NOT NULL AND eft.rag_document_fragment_id IS NULL) THEN 1 ELSE 0 END) as left - FROM uploads - LEFT OUTER JOIN rag_document_fragments rdf ON uploads.id = rdf.upload_id AND rdf.target_id = :target_id - AND rdf.target_type = :target_type - LEFT OUTER JOIN #{embeddings_table} eft ON rdf.id = eft.rag_document_fragment_id - WHERE uploads.id IN (:upload_ids) - GROUP BY uploads.id - SQL - target_id: persona.id, - target_type: persona.class.to_s, - upload_ids: uploads.map(&:id), - ) - - results.reduce({}) do |acc, r| - acc[r.id] = { total: r.total, indexed: r.indexed, left: r.left } - acc - end - end - - def publish_status(upload, status) - MessageBus.publish("/discourse-ai/rag/#{upload.id}", status, user_ids: [upload.user_id]) - end - end -end - -# == Schema Information -# -# Table name: rag_document_fragments -# -# id :bigint not null, primary key -# fragment :text not null -# upload_id :integer not null -# fragment_number :integer not null -# created_at :datetime not null -# updated_at :datetime not null -# metadata :text -# target_id :bigint not null -# target_type :string(800) not null -# -# Indexes -# -# index_rag_document_fragments_on_target_type_and_target_id (target_type,target_id) -# diff --git a/app/models/reviewable_ai_chat_message.rb b/app/models/reviewable_ai_chat_message.rb deleted file mode 100644 index 175046e8..00000000 --- a/app/models/reviewable_ai_chat_message.rb +++ /dev/null @@ -1,188 +0,0 @@ -# frozen_string_literal: true - -require_dependency "reviewable" - -class ReviewableAiChatMessage < Reviewable - def self.action_aliases - { - agree_and_keep_hidden: :agree_and_delete, - agree_and_silence: :agree_and_delete, - agree_and_suspend: :agree_and_delete, - delete_and_agree: :agree_and_delete, - } - end - - def self.score_to_silence_user - sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6) - end - - def chat_message - @chat_message ||= (target || Chat::Message.with_deleted.find_by(id: target_id)) - end - - def chat_message_creator - @chat_message_creator ||= chat_message.user - end - - def flagged_by_user_ids - @flagged_by_user_ids ||= reviewable_scores.map(&:user_id) - end - - def post - nil - end - - def build_actions(actions, guardian, args) - return unless pending? - - return build_action(actions, :ignore, icon: "up-right-from-square") if chat_message.blank? - - agree = - actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title") - - if chat_message.deleted_at? - build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) - build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree) - build_action(actions, :disagree_and_restore, icon: "thumbs-down") - else - build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree) - build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree) - build_action(actions, :disagree, icon: "thumbs-down") - end - - if guardian.can_suspend?(chat_message_creator) - build_action( - actions, - :agree_and_suspend, - icon: "ban", - bundle: agree, - client_action: "suspend", - ) - build_action( - actions, - :agree_and_silence, - icon: "microphone-slash", - bundle: agree, - client_action: "silence", - ) - end - - build_action(actions, :ignore, icon: "up-right-from-square") - - build_action(actions, :delete_and_agree, icon: "far-trash-can") unless chat_message.deleted_at? - end - - def perform_agree_and_keep_message(performed_by, args) - agree - end - - def perform_agree_and_restore(performed_by, args) - agree { chat_message.recover! } - end - - def perform_agree_and_delete(performed_by, args) - agree { chat_message.trash!(performed_by) } - end - - def perform_disagree_and_restore(performed_by, args) - disagree { chat_message.recover! } - end - - def perform_disagree(performed_by, args) - disagree - end - - def perform_ignore(performed_by, args) - ignore - end - - def perform_delete_and_ignore(performed_by, args) - ignore { chat_message.trash!(performed_by) } - end - - private - - def agree - yield if block_given? - create_result(:success, :approved) do |result| - result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids } - result.recalculate_score = true - end - end - - def disagree - yield if block_given? - - UserSilencer.unsilence(chat_message_creator) - - create_result(:success, :rejected) do |result| - result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids } - result.recalculate_score = true - end - end - - def ignore - yield if block_given? - create_result(:success, :ignored) do |result| - result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids } - end - end - - def build_action( - actions, - id, - icon:, - button_class: nil, - bundle: nil, - client_action: nil, - confirm: false - ) - actions.add(id, bundle: bundle) do |action| - prefix = "reviewables.actions.#{id}" - action.icon = icon - action.button_class = button_class - action.label = "chat.#{prefix}.title" - action.description = "chat.#{prefix}.description" - action.client_action = client_action - action.confirm_message = "#{prefix}.confirm" if confirm - end - end -end - -# == Schema Information -# -# Table name: reviewables -# -# id :bigint not null, primary key -# type :string not null -# status :integer default("pending"), not null -# created_by_id :integer not null -# reviewable_by_moderator :boolean default(FALSE), not null -# category_id :integer -# topic_id :integer -# score :float default(0.0), not null -# potential_spam :boolean default(FALSE), not null -# target_id :integer -# target_type :string -# target_created_by_id :integer -# payload :json -# version :integer default(0), not null -# latest_score :datetime -# created_at :datetime not null -# updated_at :datetime not null -# force_review :boolean default(FALSE), not null -# reject_reason :text -# potentially_illegal :boolean default(FALSE) -# type_source :string default("unknown"), not null -# -# Indexes -# -# idx_reviewables_score_desc_created_at_desc (score,created_at) -# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id) -# index_reviewables_on_status_and_created_at (status,created_at) -# index_reviewables_on_status_and_score (status,score) -# index_reviewables_on_status_and_type (status,type) -# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text) -# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id) -# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE -# diff --git a/app/models/reviewable_ai_post.rb b/app/models/reviewable_ai_post.rb deleted file mode 100644 index a89744e2..00000000 --- a/app/models/reviewable_ai_post.rb +++ /dev/null @@ -1,245 +0,0 @@ -# frozen_string_literal:true - -require_dependency "reviewable" - -class ReviewableAiPost < Reviewable - # Penalties are handled by the modal after the action is performed - def self.action_aliases - { - agree_and_keep_hidden: :agree_and_keep, - agree_and_silence: :agree_and_keep, - agree_and_suspend: :agree_and_keep, - disagree_and_restore: :disagree, - } - end - - def build_actions(actions, guardian, args) - return actions if !pending? || post.blank? - - agree = - actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title") - - if !post.user_deleted? && !post.hidden? - build_action(actions, :agree_and_hide, icon: "far-eye-slash", bundle: agree) - end - - if post.hidden? - build_action(actions, :agree_and_keep_hidden, icon: "thumbs-up", bundle: agree) - else - build_action(actions, :agree_and_keep, icon: "thumbs-up", bundle: agree) - end - - if guardian.can_suspend?(target_created_by) - build_action( - actions, - :agree_and_suspend, - icon: "ban", - bundle: agree, - client_action: "suspend", - ) - build_action( - actions, - :agree_and_silence, - icon: "microphone-slash", - bundle: agree, - client_action: "silence", - ) - end - - build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) if post.user_deleted? - - if post.hidden? - build_action(actions, :disagree_and_restore, icon: "thumbs-down") - else - build_action(actions, :disagree, icon: "thumbs-down") - end - - if guardian.can_delete_post_or_topic?(post) - delete = - actions.add_bundle( - "#{id}-delete", - icon: "far-trash-can", - label: "reviewables.actions.delete.title", - ) - build_action(actions, :delete_and_ignore, icon: "up-right-from-square", bundle: delete) - if post.reply_count > 0 - build_action( - actions, - :delete_and_ignore_replies, - icon: "up-right-from-square", - confirm: true, - bundle: delete, - ) - end - build_action(actions, :delete_and_agree, icon: "thumbs-up", bundle: delete) - if post.reply_count > 0 - build_action( - actions, - :delete_and_agree_replies, - icon: "up-right-from-square", - bundle: delete, - confirm: true, - ) - end - end - - delete_user_actions(actions) if guardian.can_delete_user?(target_created_by) - - build_action(actions, :ignore, icon: "up-right-from-square") - end - - def perform_agree_and_hide(performed_by, args) - post.hide!(reviewable_scores.first.reviewable_score_type) - - agree - end - - def perform_agree_and_keep(_performed_by, _args) - agree - end - - def perform_agree_and_restore(performed_by, args) - destroyer(performed_by).recover - agree - end - - def perform_disagree(performed_by, args) - # Undo hide/silence if applicable - post.unhide! if post.hidden? - - create_result(:success, :rejected) do |result| - result.update_flag_stats = { status: :disagreed, user_ids: [created_by_id] } - end - end - - def perform_ignore(performed_by, args) - create_result(:success, :ignored) do |result| - result.update_flag_stats = { status: :ignored, user_ids: [created_by_id] } - end - end - - def perform_delete_and_ignore(performed_by, args) - destroyer(performed_by).destroy - - perform_ignore(performed_by, args) - end - - def perform_delete_and_agree(performed_by, args) - destroyer(performed_by).destroy - - agree - end - - def perform_delete_and_ignore_replies(performed_by, args) - PostDestroyer.delete_with_replies(performed_by, post, self) - - perform_ignore(performed_by, args) - end - - def perform_delete_and_agree_replies(performed_by, args) - PostDestroyer.delete_with_replies(performed_by, post, self) - - agree - end - - def perform_delete_user(performed_by, args) - UserDestroyer.new(performed_by).destroy(post.user, delete_opts) - - agree - end - - def perform_delete_user_block(performed_by, args) - delete_options = delete_opts - - delete_options.merge!(block_email: true, block_ip: true) if Rails.env.production? - - UserDestroyer.new(performed_by).destroy(post.user, delete_options) - - agree - end - - private - - def post - @post ||= (target || Post.with_deleted.find_by(id: target_id)) - end - - def destroyer(performed_by) - PostDestroyer.new(performed_by, post, reviewable: self) - end - - def agree - create_result(:success, :approved) do |result| - result.update_flag_stats = { status: :agreed, user_ids: [created_by_id] } - result.recalculate_score = true - end - end - - def delete_opts - { - delete_posts: true, - prepare_for_destroy: true, - block_urls: true, - delete_as_spammer: true, - context: "review", - } - end - - def build_action( - actions, - id, - icon:, - button_class: nil, - bundle: nil, - client_action: nil, - confirm: false - ) - actions.add(id, bundle: bundle) do |action| - prefix = "reviewables.actions.#{id}" - action.icon = icon - action.button_class = button_class - action.label = "#{prefix}.title" - action.description = "#{prefix}.description" - action.client_action = client_action - action.confirm_message = "#{prefix}.confirm" if confirm - end - end -end - -# == Schema Information -# -# Table name: reviewables -# -# id :bigint not null, primary key -# type :string not null -# status :integer default("pending"), not null -# created_by_id :integer not null -# reviewable_by_moderator :boolean default(FALSE), not null -# category_id :integer -# topic_id :integer -# score :float default(0.0), not null -# potential_spam :boolean default(FALSE), not null -# target_id :integer -# target_type :string -# target_created_by_id :integer -# payload :json -# version :integer default(0), not null -# latest_score :datetime -# created_at :datetime not null -# updated_at :datetime not null -# force_review :boolean default(FALSE), not null -# reject_reason :text -# potentially_illegal :boolean default(FALSE) -# type_source :string default("unknown"), not null -# -# Indexes -# -# idx_reviewables_score_desc_created_at_desc (score,created_at) -# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id) -# index_reviewables_on_status_and_created_at (status,created_at) -# index_reviewables_on_status_and_score (status,score) -# index_reviewables_on_status_and_type (status,type) -# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text) -# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id) -# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE -# diff --git a/app/models/shared_ai_conversation.rb b/app/models/shared_ai_conversation.rb deleted file mode 100644 index a4485e06..00000000 --- a/app/models/shared_ai_conversation.rb +++ /dev/null @@ -1,232 +0,0 @@ -# frozen_string_literal: true - -class SharedAiConversation < ActiveRecord::Base - DEFAULT_MAX_POSTS = 100 - - belongs_to :user - belongs_to :target, polymorphic: true - - validates :user_id, presence: true - validates :target, presence: true - validates :context, presence: true - validates :share_key, presence: true, uniqueness: true - - before_validation :generate_share_key, on: :create - - def self.share_conversation(user, target, max_posts: DEFAULT_MAX_POSTS) - raise "Target must be a topic for now" if !target.is_a?(Topic) - - conversation = find_by(user: user, target: target) - conversation_data = build_conversation_data(target, max_posts: max_posts) - - conversation = - if conversation - conversation.update(**conversation_data) - conversation - else - create(user_id: user.id, target: target, **conversation_data) - end - - ::Jobs.enqueue(:shared_conversation_adjust_upload_security, conversation_id: conversation.id) - - conversation - end - - def self.destroy_conversation(conversation) - conversation.destroy - - maybe_topic = conversation.target - if maybe_topic.is_a?(Topic) - AiArtifact.where(post: maybe_topic.posts).update_all( - "metadata = jsonb_set(COALESCE(metadata, '{}'), '{public}', 'false')", - ) - end - - ::Jobs.enqueue( - :shared_conversation_adjust_upload_security, - target_id: conversation.target_id, - target_type: conversation.target_type, - ) - end - - # Technically this may end up being a chat message - # but this name works - class SharedPost - attr_accessor :user - attr_reader :id, :user_id, :created_at, :cooked, :persona - def initialize(post) - @id = post[:id] - @user_id = post[:user_id] - @created_at = DateTime.parse(post[:created_at]) - @cooked = post[:cooked] - @persona = post[:persona] - end - end - - def populated_context - return @populated_context if @populated_context - @populated_context = context.map { |post| SharedPost.new(post.symbolize_keys) } - populate_user_info!(@populated_context) - @populated_context - end - - def to_json - posts = - self.populated_context.map do |post| - { - id: post.id, - cooked: post.cooked, - username: post.user.username, - created_at: post.created_at, - } - end - { llm_name: self.llm_name, share_key: self.share_key, title: self.title, posts: posts } - end - - def url - "#{Discourse.base_uri}/discourse-ai/ai-bot/shared-ai-conversations/#{share_key}" - end - - def html_excerpt - html = +"" - populated_context.each do |post| - text = PrettyText.excerpt(post.cooked, 400, strip_links: true, strip_details: true) - - html << "

#{post.user.username}: #{text}

" - if html.length > 1000 - html << "

...

" - break - end - end - html << "#{I18n.t("discourse_ai.share_ai.read_more")}" - html - end - - def onebox - <<~HTML -
- -
- HTML - end - - def self.excerpt(posts) - excerpt = +"" - posts.each do |post| - excerpt << "#{post.user.display_name}: #{post.excerpt(100)} " - break if excerpt.length > 1000 - end - excerpt - end - - def formatted_excerpt - I18n.t("discourse_ai.share_ai.formatted_excerpt", llm_name: llm_name, excerpt: excerpt) - end - - def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_usernames: false) - allowed_user_ids = topic.topic_allowed_users.pluck(:user_id) - ai_bot_participant = DiscourseAi::AiBot::EntryPoint.find_participant_in(allowed_user_ids) - - llm_name = ai_bot_participant&.llm - - llm_name = ActiveSupport::Inflector.humanize(llm_name) if llm_name - llm_name ||= I18n.t("discourse_ai.unknown_model") - - persona = nil - if persona_id = topic.custom_fields["ai_persona_id"] - persona = AiPersona.find_by(id: persona_id.to_i)&.name - end - - posts = - topic - .posts - .by_post_number - .where(post_type: Post.types[:regular]) - .where.not(cooked: nil) - .where(deleted_at: nil) - .limit(max_posts) - - { - llm_name: llm_name, - title: topic.title, - excerpt: excerpt(posts), - context: - posts.map do |post| - mapped = { - id: post.id, - user_id: post.user_id, - created_at: post.created_at, - cooked: cook_artifacts(post), - } - - mapped[:persona] = persona if ai_bot_participant&.id == post.user_id - mapped[:username] = post.user&.username if include_usernames - mapped - end, - } - end - - def self.cook_artifacts(post) - html = post.cooked - return html if !%w[lax hybrid strict].include?(SiteSetting.ai_artifact_security) - - doc = Nokogiri::HTML5.fragment(html) - doc - .css("div.ai-artifact") - .each do |node| - id = node["data-ai-artifact-id"].to_i - version = node["data-ai-artifact-version"] - version_number = version.to_i if version - if id > 0 - AiArtifact.share_publicly(id: id, post: post) - node.replace(AiArtifact.iframe_for(id, version_number)) - end - end - - doc.to_s - end - - private - - def populate_user_info!(posts) - users = User.where(id: posts.map(&:user_id).uniq).map { |u| [u.id, u] }.to_h - posts.each { |post| post.user = users[post.user_id] } - end - - def generate_share_key - self.share_key = SecureRandom.urlsafe_base64(16) - end -end - -# == Schema Information -# -# Table name: shared_ai_conversations -# -# id :bigint not null, primary key -# user_id :integer not null -# target_id :integer not null -# target_type :string not null -# title :string not null -# llm_name :string not null -# context :jsonb not null -# share_key :string not null -# excerpt :string not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# idx_shared_ai_conversations_user_target (user_id,target_id,target_type) UNIQUE -# index_shared_ai_conversations_on_share_key (share_key) UNIQUE -# index_shared_ai_conversations_on_target_id_and_target_type (target_id,target_type) UNIQUE -# diff --git a/app/serializers/ai_api_audit_log_serializer.rb b/app/serializers/ai_api_audit_log_serializer.rb deleted file mode 100644 index eeb3843a..00000000 --- a/app/serializers/ai_api_audit_log_serializer.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class AiApiAuditLogSerializer < ApplicationSerializer - attributes :id, - :provider_id, - :user_id, - :request_tokens, - :response_tokens, - :raw_request_payload, - :raw_response_payload, - :topic_id, - :post_id, - :feature_name, - :language_model, - :created_at, - :prev_log_id, - :next_log_id -end diff --git a/app/serializers/ai_artifact_key_value_serializer.rb b/app/serializers/ai_artifact_key_value_serializer.rb deleted file mode 100644 index ac7fc780..00000000 --- a/app/serializers/ai_artifact_key_value_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AiArtifactKeyValueSerializer < ApplicationSerializer - attributes :id, :key, :value, :public, :user_id, :created_at, :updated_at - - def include_value? - !options[:keys_only] - end -end diff --git a/app/serializers/ai_chat_channel_serializer.rb b/app/serializers/ai_chat_channel_serializer.rb deleted file mode 100644 index 4f9869c4..00000000 --- a/app/serializers/ai_chat_channel_serializer.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class AiChatChannelSerializer < ApplicationSerializer - attributes :id, :chatable, :chatable_type, :chatable_url, :slug - - def chatable - case object.chatable_type - when "Category" - BasicCategorySerializer.new(object.chatable, root: false).as_json - when "DirectMessage" - Chat::DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json - when "Site" - nil - end - end - - def title - # Display all participants for a DM. - # For category channels, the argument is ignored. - object.title(nil) - end -end diff --git a/app/serializers/ai_custom_tool_list_serializer.rb b/app/serializers/ai_custom_tool_list_serializer.rb deleted file mode 100644 index 36642a68..00000000 --- a/app/serializers/ai_custom_tool_list_serializer.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -class AiCustomToolListSerializer < ApplicationSerializer - attributes :meta - - has_many :ai_tools, serializer: AiCustomToolSerializer, embed: :objects - - def meta - { - presets: AiTool.presets, - llms: DiscourseAi::Configuration::LlmEnumerator.values_for_serialization, - settings: { - rag_images_enabled: SiteSetting.ai_rag_images_enabled, - }, - } - end - - def ai_tools - object[:ai_tools] - end -end diff --git a/app/serializers/ai_custom_tool_serializer.rb b/app/serializers/ai_custom_tool_serializer.rb deleted file mode 100644 index 2af1a89f..00000000 --- a/app/serializers/ai_custom_tool_serializer.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class AiCustomToolSerializer < ApplicationSerializer - attributes :id, - :name, - :tool_name, - :description, - :summary, - :parameters, - :script, - :rag_chunk_tokens, - :rag_chunk_overlap_tokens, - :rag_llm_model_id, - :created_by_id, - :created_at, - :updated_at - - self.root = "ai_tool" - - has_many :rag_uploads, serializer: UploadSerializer, embed: :object - - def rag_uploads - object.uploads - end -end diff --git a/app/serializers/ai_embedding_definition_serializer.rb b/app/serializers/ai_embedding_definition_serializer.rb deleted file mode 100644 index 8c5b17b3..00000000 --- a/app/serializers/ai_embedding_definition_serializer.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -class AiEmbeddingDefinitionSerializer < ApplicationSerializer - root "ai_embedding" - - attributes :id, - :display_name, - :dimensions, - :max_sequence_length, - :pg_function, - :provider, - :url, - :api_key, - :seeded, - :tokenizer_class, - :embed_prompt, - :search_prompt, - :matryoshka_dimensions, - :provider_params - - def api_key - object.seeded? ? "********" : object.api_key - end - - def url - object.seeded? ? "********" : object.url - end - - def provider - object.seeded? ? "CDCK" : object.provider - end -end diff --git a/app/serializers/ai_features_persona_serializer.rb b/app/serializers/ai_features_persona_serializer.rb deleted file mode 100644 index 34cd4dd8..00000000 --- a/app/serializers/ai_features_persona_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class AiFeaturesPersonaSerializer < ApplicationSerializer - attributes :id, :name, :allowed_groups - - def allowed_groups - Group - .where(id: object.allowed_group_ids) - .pluck(:id, :name) - .map { |id, name| { id: id, name: name } } - end -end diff --git a/app/serializers/ai_inferred_concept_post_serializer.rb b/app/serializers/ai_inferred_concept_post_serializer.rb deleted file mode 100644 index fb7ff322..00000000 --- a/app/serializers/ai_inferred_concept_post_serializer.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class AiInferredConceptPostSerializer < ApplicationSerializer - attributes :id, - :post_number, - :topic_id, - :topic_title, - :username, - :avatar_template, - :created_at, - :updated_at, - :excerpt, - :truncated, - :inferred_concepts - - def avatar_template - User.avatar_template(object.username, object.uploaded_avatar_id) - end - - def excerpt - Post.excerpt(object.cooked) - end - - def truncated - object.cooked.length > SiteSetting.post_excerpt_maxlength - end - - def inferred_concepts - ActiveModel::ArraySerializer.new( - object.inferred_concepts, - each_serializer: InferredConceptSerializer, - ) - end -end diff --git a/app/serializers/ai_sentiment_post_serializer.rb b/app/serializers/ai_sentiment_post_serializer.rb deleted file mode 100644 index 53668db6..00000000 --- a/app/serializers/ai_sentiment_post_serializer.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class AiSentimentPostSerializer < ApplicationSerializer - attributes :post_id, - :topic_id, - :topic_title, - :post_number, - :username, - :name, - :avatar_template, - :excerpt, - :sentiment, - :truncated, - :category_id, - :created_at - - def avatar_template - User.avatar_template(object.username, object.uploaded_avatar_id) - end - - def excerpt - Post.excerpt(object.post_cooked) - end - - def truncated - object.post_cooked.length > SiteSetting.post_excerpt_maxlength - end -end diff --git a/app/serializers/ai_spam_serializer.rb b/app/serializers/ai_spam_serializer.rb deleted file mode 100644 index dc1212d3..00000000 --- a/app/serializers/ai_spam_serializer.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -class AiSpamSerializer < ApplicationSerializer - attributes :is_enabled, - :llm_id, - :custom_instructions, - :available_llms, - :stats, - :flagging_username, - :spam_score_type, - :spam_scanning_user, - :ai_persona_id, - :available_personas - - def is_enabled - object[:enabled] - end - - def llm_id - settings&.llm_model&.id - end - - def ai_persona_id - settings&.ai_persona&.id || - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::SpamDetector] - end - - def custom_instructions - settings&.custom_instructions - end - - def available_llms - DiscourseAi::Configuration::LlmEnumerator - .values(allowed_seeded_llms: SiteSetting.ai_spam_detection_model_allowed_seeded_models_map) - .map { |hash| { id: hash[:value], name: hash[:name] } } - end - - def available_personas - DiscourseAi::Configuration::PersonaEnumerator.values.map do |h| - { id: h[:value], name: h[:name] } - end - end - - def flagging_username - object[:flagging_username] - end - - def spam_score_type - ReviewableScore.types[:spam] - end - - def stats - { - scanned_count: object[:stats].scanned_count.to_i, - spam_detected: object[:stats].spam_detected.to_i, - false_positives: object[:stats].false_positives.to_i, - false_negatives: object[:stats].false_negatives.to_i, - } - end - - def settings - object[:settings] - end - - def spam_scanning_user - user = DiscourseAi::AiModeration::SpamScanner.flagging_user - - user.serializable_hash(only: %i[id username name admin]) if user.present? - end -end diff --git a/app/serializers/ai_tool_serializer.rb b/app/serializers/ai_tool_serializer.rb deleted file mode 100644 index 0d0c48a3..00000000 --- a/app/serializers/ai_tool_serializer.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class AiToolSerializer < ApplicationSerializer - attributes :options, :id, :name, :help - - def include_options? - object.accepted_options.present? - end - - def id - object.to_s.split("::").last - end - - def name - object.name.humanize.titleize - end - - def help - object.help - end - - def options - options = {} - object.accepted_options.each do |option| - processed_option = { - name: option.localized_name, - description: option.localized_description, - type: option.type, - } - processed_option[:values] = option.values if option.values.present? - processed_option[:default] = option.default if option.default.present? - options[option.name] = processed_option - end - options - end -end diff --git a/app/serializers/ai_topic_summary_serializer.rb b/app/serializers/ai_topic_summary_serializer.rb deleted file mode 100644 index 7ffff9e2..00000000 --- a/app/serializers/ai_topic_summary_serializer.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class AiTopicSummarySerializer < ApplicationSerializer - attributes :summarized_text, - :algorithm, - :outdated, - :can_regenerate, - :new_posts_since_summary, - :updated_at - - def can_regenerate - scope.can_request_summary? - end - - def new_posts_since_summary - object.target.highest_post_number.to_i - object.highest_target_number.to_i - end -end diff --git a/app/serializers/ai_usage_serializer.rb b/app/serializers/ai_usage_serializer.rb deleted file mode 100644 index 0a990547..00000000 --- a/app/serializers/ai_usage_serializer.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -class AiUsageSerializer < ApplicationSerializer - attributes :data, :features, :models, :users, :summary, :period - - def data - object.tokens_by_period.as_json( - only: %i[period total_tokens total_cached_tokens total_request_tokens total_response_tokens], - ) - end - - def period - object.guess_period - end - - def features - object.feature_breakdown.as_json( - only: %i[ - feature_name - usage_count - total_tokens - total_cached_tokens - total_request_tokens - total_response_tokens - input_spending - output_spending - cached_input_spending - ], - ) - end - - def models - object.model_breakdown.as_json( - only: %i[ - llm - usage_count - total_tokens - total_cached_tokens - total_request_tokens - total_response_tokens - input_spending - output_spending - cached_input_spending - ], - ) - end - - def users - object.user_breakdown.map do |user| - { - avatar_template: User.avatar_template(user.username, user.uploaded_avatar_id), - username: user.username, - usage_count: user.usage_count, - total_tokens: user.total_tokens, - total_cached_tokens: user.total_cached_tokens, - total_request_tokens: user.total_request_tokens, - total_response_tokens: user.total_response_tokens, - input_spending: user.input_spending, - output_spending: user.output_spending, - cached_input_spending: user.cached_input_spending, - } - end - end - - def summary - { - total_tokens: object.total_tokens, - total_cached_tokens: object.total_cached_tokens, - total_request_tokens: object.total_request_tokens, - total_response_tokens: object.total_response_tokens, - total_requests: object.total_requests, - total_spending: object.total_spending, - date_range: { - start: object.start_date, - end: object.end_date, - }, - } - end -end diff --git a/app/serializers/basic_llm_model_serializer.rb b/app/serializers/basic_llm_model_serializer.rb deleted file mode 100644 index e5f13f1f..00000000 --- a/app/serializers/basic_llm_model_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class BasicLlmModelSerializer < ApplicationSerializer - attributes :id, :display_name -end diff --git a/app/serializers/inferred_concept_serializer.rb b/app/serializers/inferred_concept_serializer.rb deleted file mode 100644 index e2ed704c..00000000 --- a/app/serializers/inferred_concept_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class InferredConceptSerializer < ApplicationSerializer - attributes :id, :name, :created_at, :updated_at -end diff --git a/app/serializers/llm_model_serializer.rb b/app/serializers/llm_model_serializer.rb deleted file mode 100644 index 736757d4..00000000 --- a/app/serializers/llm_model_serializer.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -class LlmModelSerializer < ApplicationSerializer - # TODO: we probably should rename the table LlmModel to AiLlm - # it is consistent with AiPersona and AiTool - # LLM model is a bit confusing given that large langauge model model is a confusing - # name - root "ai_llm" - attributes :id, - :display_name, - :name, - :provider, - :max_prompt_tokens, - :max_output_tokens, - :tokenizer, - :api_key, - :url, - :enabled_chat_bot, - :provider_params, - :vision_enabled, - :input_cost, - :output_cost, - :cached_input_cost, - :used_by - - has_one :user, serializer: BasicUserSerializer, embed: :object - has_many :llm_quotas, serializer: LlmQuotaSerializer, embed: :objects - - def used_by - llm_usage = - ( - if (scope && scope[:llm_usage]) - scope[:llm_usage] - else - DiscourseAi::Configuration::LlmEnumerator.global_usage - end - ) - - llm_usage[object.id] - end - - def api_key - object.seeded? ? "********" : object.api_key - end - - def url - object.seeded? ? "********" : object.url - end - - def provider - object.seeded? ? "CDCK" : object.provider - end -end diff --git a/app/serializers/llm_quota_serializer.rb b/app/serializers/llm_quota_serializer.rb deleted file mode 100644 index 4db1fb7f..00000000 --- a/app/serializers/llm_quota_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class LlmQuotaSerializer < ApplicationSerializer - attributes :id, :group_id, :llm_model_id, :max_tokens, :max_usages, :duration_seconds, :group_name - - def group_name - object.group.name - end -end diff --git a/app/serializers/localized_ai_persona_serializer.rb b/app/serializers/localized_ai_persona_serializer.rb deleted file mode 100644 index 90e14c0d..00000000 --- a/app/serializers/localized_ai_persona_serializer.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -class LocalizedAiPersonaSerializer < ApplicationSerializer - root "ai_persona" - - attributes :id, - :name, - :description, - :enabled, - :system, - :priority, - :tools, - :system_prompt, - :allowed_group_ids, - :temperature, - :top_p, - :default_llm_id, - :user_id, - :max_context_posts, - :vision_enabled, - :vision_max_pixels, - :rag_chunk_tokens, - :rag_chunk_overlap_tokens, - :rag_conversation_chunks, - :rag_llm_model_id, - :question_consolidator_llm_id, - :tool_details, - :forced_tool_count, - :allow_chat_channel_mentions, - :allow_chat_direct_messages, - :allow_topic_mentions, - :allow_personal_messages, - :force_default_llm, - :response_format, - :examples, - :features - - has_one :user, serializer: BasicUserSerializer, embed: :object - has_many :rag_uploads, serializer: UploadSerializer, embed: :object - has_one :default_llm, serializer: BasicLlmModelSerializer, embed: :object - - def rag_uploads - object.uploads - end - - def name - object.class_instance.name - end - - def description - object.class_instance.description - end - - def default_llm - LlmModel.find_by(id: object.default_llm_id) - end - - def features - object.features.map do |feature| - { id: feature.module_id, module_name: feature.module_name, name: feature.name } - end - end -end diff --git a/app/serializers/reviewable_ai_chat_message_serializer.rb b/app/serializers/reviewable_ai_chat_message_serializer.rb deleted file mode 100644 index 241f3aad..00000000 --- a/app/serializers/reviewable_ai_chat_message_serializer.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require_dependency "reviewable_serializer" - -class ReviewableAiChatMessageSerializer < ReviewableSerializer - payload_attributes :accuracies, :message_cooked - target_attributes :cooked - attributes :target_id - - has_one :chat_channel, serializer: AiChatChannelSerializer, root: false, embed: :objects - - def chat_channel - object.chat_message&.chat_channel - end - - def target_id - object.target&.id - end -end diff --git a/app/serializers/reviewable_ai_post_serializer.rb b/app/serializers/reviewable_ai_post_serializer.rb deleted file mode 100644 index bc7721d8..00000000 --- a/app/serializers/reviewable_ai_post_serializer.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require_dependency "reviewable_flagged_post_serializer" - -class ReviewableAiPostSerializer < ReviewableFlaggedPostSerializer - payload_attributes :accuracies -end diff --git a/app/services/discourse_ai/persona_exporter.rb b/app/services/discourse_ai/persona_exporter.rb deleted file mode 100644 index 9beadc20..00000000 --- a/app/services/discourse_ai/persona_exporter.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - class PersonaExporter - def initialize(persona:) - raise ArgumentError, "Invalid persona provided" if !persona.is_a?(AiPersona) - @persona = persona - end - - def export - serialized_custom_tools = serialize_tools(@persona) - serialize_persona(@persona, serialized_custom_tools) - end - - private - - def serialize_tools(ai_persona) - custom_tool_ids = - (ai_persona.tools || []).filter_map do |tool_config| - # A tool config is an array like: ["custom-ID", {options}, force_flag] - if tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-") - tool_config[0].split("-", 2).last.to_i - end - end - - return [] if custom_tool_ids.empty? - - tools = AiTool.where(id: custom_tool_ids) - tools.map do |tool| - { - identifier: tool.tool_name, # Use tool_name for portability - name: tool.name, - description: tool.description, - tool_name: tool.tool_name, - parameters: tool.parameters, - summary: tool.summary, - script: tool.script, - } - end - end - - def serialize_persona(ai_persona, serialized_custom_tools) - export_data = { - meta: { - version: "1.0", - exported_at: Time.zone.now.iso8601, - }, - persona: { - name: ai_persona.name, - description: ai_persona.description, - system_prompt: ai_persona.system_prompt, - examples: ai_persona.examples, - temperature: ai_persona.temperature, - top_p: ai_persona.top_p, - response_format: ai_persona.response_format, - tools: transform_tools_for_export(ai_persona.tools, serialized_custom_tools), - }, - custom_tools: serialized_custom_tools, - } - - JSON.pretty_generate(export_data) - end - - def transform_tools_for_export(tools_config, _serialized_custom_tools) - return [] if tools_config.blank? - - tools_config.map do |tool_config| - unless tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-") - next tool_config - end - - tool_id = tool_config[0].split("-", 2).last.to_i - tool = AiTool.find_by(id: tool_id) - next tool_config unless tool - - ["custom-#{tool.tool_name}", tool_config[1], tool_config[2]] - end - end - end -end diff --git a/app/services/discourse_ai/persona_importer.rb b/app/services/discourse_ai/persona_importer.rb deleted file mode 100644 index 6fecc2ae..00000000 --- a/app/services/discourse_ai/persona_importer.rb +++ /dev/null @@ -1,149 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - class PersonaImporter - class ImportError < StandardError - attr_reader :conflicts - - def initialize(message, conflicts = {}) - super(message) - @conflicts = conflicts - end - end - - def initialize(json:) - @data = - case json - when String - JSON.parse(json) - when Hash - json - else - raise ArgumentError, "Invalid JSON payload" - end - - validate_payload! - end - - def import!(overwrite: false) - ActiveRecord::Base.transaction do - check_conflicts! unless overwrite - - tool_name_to_id = import_custom_tools(@data["custom_tools"] || [], overwrite: overwrite) - persona_data = @data["persona"] - - existing_persona = AiPersona.find_by(name: persona_data["name"]) - - attrs = { - description: persona_data["description"], - system_prompt: persona_data["system_prompt"], - examples: persona_data["examples"], - temperature: persona_data["temperature"], - top_p: persona_data["top_p"], - response_format: persona_data["response_format"], - tools: transform_tools_for_import(persona_data["tools"], tool_name_to_id), - } - - if existing_persona && overwrite - existing_persona.update!(**attrs) - existing_persona - else - attrs[:name] = persona_data["name"] - AiPersona.create!(**attrs) - end - end - end - - private - - def validate_payload! - unless @data.is_a?(Hash) && @data["persona"].is_a?(Hash) - raise ArgumentError, "Invalid persona export data" - end - end - - def check_conflicts! - conflicts = {} - - persona_name = @data["persona"]["name"] - conflicts[:persona] = persona_name if AiPersona.exists?(name: persona_name) - - if @data["custom_tools"].present? - existing_tools = [] - @data["custom_tools"].each do |tool_data| - tool_name = tool_data["tool_name"] || tool_data["identifier"] - existing_tools << tool_name if AiTool.exists?(tool_name: tool_name) - end - conflicts[:custom_tools] = existing_tools if existing_tools.any? - end - - if conflicts.any? - message = build_conflict_message(conflicts) - raise ImportError.new(message, conflicts) - end - end - - def build_conflict_message(conflicts) - messages = [] - - if conflicts[:persona] - messages << I18n.t("discourse_ai.errors.persona_already_exists", name: conflicts[:persona]) - end - - if conflicts[:custom_tools] && conflicts[:custom_tools].any? - tools_list = conflicts[:custom_tools].join(", ") - error = - I18n.t( - "discourse_ai.errors.custom_tool_exists", - names: tools_list, - count: conflicts[:custom_tools].size, - ) - messages << error - end - - messages.join("\n") - end - - def import_custom_tools(custom_tools, overwrite:) - return {} if custom_tools.blank? - - custom_tools.each_with_object({}) do |tool_data, map| - tool_name = tool_data["tool_name"] || tool_data["identifier"] - - if overwrite - tool = AiTool.find_or_initialize_by(tool_name: tool_name) - else - tool = AiTool.new(tool_name: tool_name) - end - - tool.tap do |t| - t.name = tool_data["name"] - t.description = tool_data["description"] - t.parameters = tool_data["parameters"] - t.script = tool_data["script"] - t.summary = tool_data["summary"] - t.created_by ||= Discourse.system_user - t.save! - end - - map[tool.tool_name] = tool.id - end - end - - def transform_tools_for_import(tools_config, tool_name_to_id) - return [] if tools_config.blank? - - tools_config.map do |tool_config| - if tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-") - tool_name = tool_config[0].split("-", 2).last - tool_id = tool_name_to_id[tool_name] || AiTool.find_by(tool_name: tool_name)&.id - raise ArgumentError, "Custom tool '#{tool_name}' not found" unless tool_id - - ["custom-#{tool_id}", tool_config[1], tool_config[2]] - else - tool_config - end - end - end - end -end diff --git a/app/services/discourse_ai/topic_summarization.rb b/app/services/discourse_ai/topic_summarization.rb deleted file mode 100644 index 3d33e21d..00000000 --- a/app/services/discourse_ai/topic_summarization.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - # A cache layer on top of our topic summarization engine. Also handle permissions. - class TopicSummarization - def self.for(topic, user) - new(DiscourseAi::Summarization.topic_summary(topic), user) - end - - def initialize(summarizer, user) - @summarizer = summarizer - @user = user - end - - def cached_summary - summarizer.existing_summary - end - - def summarize(skip_age_check: false, &on_partial_blk) - # Existing summary shouldn't be nil in this scenario because the controller checks its existence. - return if !user && !cached_summary - - return cached_summary if use_cached?(skip_age_check) - - summarizer.delete_cached_summaries! if cached_summary - - summarizer.summarize(user, &on_partial_blk) - end - - private - - attr_reader :summarizer, :user - - def use_cached?(skip_age_check) - can_summarize = Guardian.new(user).can_request_summary? - - cached_summary && - !( - can_summarize && cached_summary.outdated && - (skip_age_check || cached_summary.created_at < 1.hour.ago) - ) - end - end -end diff --git a/app/services/problem_check/ai_llm_status.rb b/app/services/problem_check/ai_llm_status.rb deleted file mode 100644 index 23739a06..00000000 --- a/app/services/problem_check/ai_llm_status.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -class ProblemCheck::AiLlmStatus < ProblemCheck - self.priority = "high" - self.perform_every = 6.hours - - def call - llm_errors - end - - private - - def llm_errors - return [] if !SiteSetting.discourse_ai_enabled - LlmModel.in_use.find_each.filter_map do |model| - try_validate(model) { validator.run_test(model) } - end - end - - def try_validate(model, &blk) - begin - blk.call - nil - rescue => e - details = { - model_id: model.id, - model_name: model.display_name, - error: parse_error_message(e.message), - url: "#{Discourse.base_path}/admin/plugins/discourse-ai/ai-llms/#{model.id}/edit", - } - - message = I18n.t("dashboard.problem.ai_llm_status", details) - - Problem.new( - message, - priority: "high", - identifier: "ai_llm_status", - target: model.id, - details:, - ) - end - end - - def validator - @validator ||= DiscourseAi::Configuration::LlmValidator.new - end - - def parse_error_message(message) - begin - JSON.parse(message)["message"] - rescue JSON::ParserError - message.to_s - end - end -end diff --git a/app/views/discourse_ai/ai_bot/shared_ai_conversations/show.html.erb b/app/views/discourse_ai/ai_bot/shared_ai_conversations/show.html.erb deleted file mode 100644 index 6b3273ef..00000000 --- a/app/views/discourse_ai/ai_bot/shared_ai_conversations/show.html.erb +++ /dev/null @@ -1,80 +0,0 @@ - - - - <%= I18n.t("discourse_ai.share_ai.title", title: @shared_conversation.title, site_name: SiteSetting.title) %> - "> - - - - - "> - - - "> - - - -
- - - <% @shared_conversation.populated_context.each do |post| %> -
-
- <%= post.user.username %> - <%if post.persona.present? %> - <%= post.persona %> - <% end %> - -
-
- <%= post.cooked.html_safe %> -
-
- <% end %> -
- - - - diff --git a/app/views/list/related_topics.html.erb b/app/views/list/related_topics.html.erb deleted file mode 100644 index 7ac68473..00000000 --- a/app/views/list/related_topics.html.erb +++ /dev/null @@ -1,58 +0,0 @@ - diff --git a/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js b/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js deleted file mode 100644 index aa2bed60..00000000 --- a/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js +++ /dev/null @@ -1,37 +0,0 @@ -export default { - resource: "admin.adminPlugins.show", - - path: "/plugins", - - map() { - this.route("discourse-ai-personas", { path: "ai-personas" }, function () { - this.route("new"); - this.route("edit", { path: "/:id/edit" }); - }); - - this.route("discourse-ai-llms", { path: "ai-llms" }, function () { - this.route("new"); - this.route("edit", { path: "/:id/edit" }); - }); - - this.route("discourse-ai-tools", { path: "ai-tools" }, function () { - this.route("new"); - this.route("edit", { path: "/:id/edit" }); - }); - this.route("discourse-ai-spam", { path: "ai-spam" }); - this.route("discourse-ai-usage", { path: "ai-usage" }); - - this.route( - "discourse-ai-embeddings", - { path: "ai-embeddings" }, - function () { - this.route("new"); - this.route("edit", { path: "/:id/edit" }); - } - ); - - this.route("discourse-ai-features", { path: "ai-features" }, function () { - this.route("edit", { path: "/:id/edit" }); - }); - }, -}; diff --git a/assets/javascripts/discourse/admin-discourse-ai-route-map.js b/assets/javascripts/discourse/admin-discourse-ai-route-map.js deleted file mode 100644 index 9643cfbc..00000000 --- a/assets/javascripts/discourse/admin-discourse-ai-route-map.js +++ /dev/null @@ -1,10 +0,0 @@ -export default { - resource: "admin.dashboard", - path: "/dashboard", - map() { - this.route("admin.dashboardSentiment", { - path: "/dashboard/sentiment", - resetNamespace: true, - }); - }, -}; diff --git a/assets/javascripts/discourse/admin/adapters/ai-embedding.js b/assets/javascripts/discourse/admin/adapters/ai-embedding.js deleted file mode 100644 index 5aa1b48c..00000000 --- a/assets/javascripts/discourse/admin/adapters/ai-embedding.js +++ /dev/null @@ -1,21 +0,0 @@ -import RestAdapter from "discourse/adapters/rest"; - -export default class Adapter extends RestAdapter { - jsonMode = true; - - basePath() { - return "/admin/plugins/discourse-ai/"; - } - - pathFor(store, type, findArgs) { - // removes underscores which are implemented in base - let path = - this.basePath(store, type, findArgs) + - store.pluralize(this.apiNameFor(type)); - return this.appendQueryParams(path, findArgs); - } - - apiNameFor() { - return "ai-embedding"; - } -} diff --git a/assets/javascripts/discourse/admin/adapters/ai-feature.js b/assets/javascripts/discourse/admin/adapters/ai-feature.js deleted file mode 100644 index 994523d1..00000000 --- a/assets/javascripts/discourse/admin/adapters/ai-feature.js +++ /dev/null @@ -1,21 +0,0 @@ -import RestAdapter from "discourse/adapters/rest"; - -export default class AiFeatureAdapter extends RestAdapter { - jsonMode = true; - - basePath() { - return "/admin/plugins/discourse-ai/"; - } - - pathFor(store, type, findArgs) { - // removes underscores which are implemented in base - let path = - this.basePath(store, type, findArgs) + - store.pluralize(this.apiNameFor(type)); - return this.appendQueryParams(path, findArgs); - } - - apiNameFor() { - return "ai-feature"; - } -} diff --git a/assets/javascripts/discourse/admin/adapters/ai-llm.js b/assets/javascripts/discourse/admin/adapters/ai-llm.js deleted file mode 100644 index fe82163d..00000000 --- a/assets/javascripts/discourse/admin/adapters/ai-llm.js +++ /dev/null @@ -1,21 +0,0 @@ -import RestAdapter from "discourse/adapters/rest"; - -export default class Adapter extends RestAdapter { - jsonMode = true; - - basePath() { - return "/admin/plugins/discourse-ai/"; - } - - pathFor(store, type, findArgs) { - // removes underscores which are implemented in base - let path = - this.basePath(store, type, findArgs) + - store.pluralize(this.apiNameFor(type)); - return this.appendQueryParams(path, findArgs); - } - - apiNameFor() { - return "ai-llm"; - } -} diff --git a/assets/javascripts/discourse/admin/adapters/ai-persona.js b/assets/javascripts/discourse/admin/adapters/ai-persona.js deleted file mode 100644 index 6a837690..00000000 --- a/assets/javascripts/discourse/admin/adapters/ai-persona.js +++ /dev/null @@ -1,21 +0,0 @@ -import RestAdapter from "discourse/adapters/rest"; - -export default class Adapter extends RestAdapter { - jsonMode = true; - - basePath() { - return "/admin/plugins/discourse-ai/"; - } - - pathFor(store, type, findArgs) { - // removes underscores which are implemented in base - let path = - this.basePath(store, type, findArgs) + - store.pluralize(this.apiNameFor(type)); - return this.appendQueryParams(path, findArgs); - } - - apiNameFor() { - return "ai-persona"; - } -} diff --git a/assets/javascripts/discourse/admin/adapters/ai-tool.js b/assets/javascripts/discourse/admin/adapters/ai-tool.js deleted file mode 100644 index bcd38472..00000000 --- a/assets/javascripts/discourse/admin/adapters/ai-tool.js +++ /dev/null @@ -1,21 +0,0 @@ -import RestAdapter from "discourse/adapters/rest"; - -export default class AiToolAdapter extends RestAdapter { - jsonMode = true; - - basePath() { - return "/admin/plugins/discourse-ai/"; - } - - pathFor(store, type, findArgs) { - // removes underscores which are implemented in base - let path = - this.basePath(store, type, findArgs) + - store.pluralize(this.apiNameFor(type)); - return this.appendQueryParams(path, findArgs); - } - - apiNameFor() { - return "ai-tool"; - } -} diff --git a/assets/javascripts/discourse/admin/models/ai-embedding.js b/assets/javascripts/discourse/admin/models/ai-embedding.js deleted file mode 100644 index d3d620fe..00000000 --- a/assets/javascripts/discourse/admin/models/ai-embedding.js +++ /dev/null @@ -1,41 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import RestModel from "discourse/models/rest"; - -export default class AiEmbedding extends RestModel { - createProperties() { - return this.getProperties( - "id", - "display_name", - "dimensions", - "provider", - "tokenizer_class", - "dimensions", - "url", - "api_key", - "max_sequence_length", - "provider_params", - "pg_function", - "embed_prompt", - "search_prompt", - "matryoshka_dimensions" - ); - } - - updateProperties() { - const attrs = this.createProperties(); - attrs.id = this.id; - - return attrs; - } - - async testConfig() { - return await ajax(`/admin/plugins/discourse-ai/ai-embeddings/test.json`, { - data: { ai_embedding: this.createProperties() }, - }); - } - - workingCopy() { - const attrs = this.createProperties(); - return this.store.createRecord("ai-embedding", attrs); - } -} diff --git a/assets/javascripts/discourse/admin/models/ai-feature.js b/assets/javascripts/discourse/admin/models/ai-feature.js deleted file mode 100644 index 43384e61..00000000 --- a/assets/javascripts/discourse/admin/models/ai-feature.js +++ /dev/null @@ -1,7 +0,0 @@ -import RestModel from "discourse/models/rest"; - -export default class AiFeature extends RestModel { - createProperties() { - return this.getProperties("id", "module", "global_enabled", "features"); - } -} diff --git a/assets/javascripts/discourse/admin/models/ai-llm.js b/assets/javascripts/discourse/admin/models/ai-llm.js deleted file mode 100644 index cafbf3a0..00000000 --- a/assets/javascripts/discourse/admin/models/ai-llm.js +++ /dev/null @@ -1,37 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import RestModel from "discourse/models/rest"; - -export default class AiLlm extends RestModel { - createProperties() { - return this.getProperties( - "id", - "display_name", - "name", - "provider", - "tokenizer", - "max_prompt_tokens", - "max_output_tokens", - "url", - "api_key", - "enabled_chat_bot", - "provider_params", - "vision_enabled", - "input_cost", - "cached_input_cost", - "output_cost" - ); - } - - updateProperties() { - const attrs = this.createProperties(); - attrs.id = this.id; - attrs.llm_quotas = this.llm_quotas; - return attrs; - } - - async testConfig(llmConfig) { - return await ajax("/admin/plugins/discourse-ai/ai-llms/test.json", { - data: { ai_llm: llmConfig ?? this.createProperties() }, - }); - } -} diff --git a/assets/javascripts/discourse/admin/models/ai-persona.js b/assets/javascripts/discourse/admin/models/ai-persona.js deleted file mode 100644 index 042bc786..00000000 --- a/assets/javascripts/discourse/admin/models/ai-persona.js +++ /dev/null @@ -1,161 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import RestModel from "discourse/models/rest"; - -const CREATE_ATTRIBUTES = [ - "id", - "name", - "description", - "tools", - "system_prompt", - "allowed_group_ids", - "enabled", - "system", - "priority", - "top_p", - "temperature", - "user_id", - "default_llm_id", - "force_default_llm", - "user", - "max_context_posts", - "vision_enabled", - "vision_max_pixels", - "rag_uploads", - "rag_chunk_tokens", - "rag_chunk_overlap_tokens", - "rag_conversation_chunks", - "rag_llm_model_id", - "question_consolidator_llm_id", - "allow_chat", - "tool_details", - "forced_tool_count", - "allow_personal_messages", - "allow_topic_mentions", - "allow_chat_channel_mentions", - "allow_chat_direct_messages", - "response_format", - "examples", -]; - -const SYSTEM_ATTRIBUTES = [ - "id", - "allowed_group_ids", - "enabled", - "system", - "priority", - "tools", - "user_id", - "default_llm_id", - "force_default_llm", - "user", - "max_context_posts", - "vision_enabled", - "vision_max_pixels", - "rag_uploads", - "rag_chunk_tokens", - "rag_chunk_overlap_tokens", - "rag_conversation_chunks", - "rag_llm_model_id", - "question_consolidator_llm_id", - "tool_details", - "allow_personal_messages", - "allow_topic_mentions", - "allow_chat_channel_mentions", - "allow_chat_direct_messages", -]; - -export default class AiPersona extends RestModel { - async createUser() { - const result = await ajax( - `/admin/plugins/discourse-ai/ai-personas/${this.id}/create-user.json`, - { - type: "POST", - } - ); - this.user = result.user; - this.user_id = this.user.id; - return this.user; - } - - flattenedToolStructure(data) { - return (data.tools || []).map((tName) => { - return [ - tName, - data.toolOptions[tName] || {}, - data.forcedTools.includes(tName), - ]; - }); - } - - // this code is here to convert the wire schema to easier to work with object - // on the wire we pass in/out tools as an Array. - // [[ToolName, {option1: value, option2: value}, force], ToolName2, ToolName3] - // We split it into tools, options and a list of forced ones. - populateTools(attrs) { - const forcedTools = []; - const toolOptions = {}; - - const flatTools = attrs.tools?.map((tool) => { - if (typeof tool === "string") { - return tool; - } else { - let [toolId, options, force] = tool; - const mappedOptions = {}; - - for (const optionId in options) { - if (!options.hasOwnProperty(optionId)) { - continue; - } - - mappedOptions[optionId] = options[optionId]; - } - - if (Object.keys(mappedOptions).length > 0) { - toolOptions[toolId] = mappedOptions; - } - - if (force) { - forcedTools.push(toolId); - } - - return toolId; - } - }); - - attrs.tools = flatTools; - attrs.forcedTools = forcedTools; - attrs.toolOptions = toolOptions; - } - - updateProperties() { - const attrs = this.system - ? this.getProperties(SYSTEM_ATTRIBUTES) - : this.getProperties(CREATE_ATTRIBUTES); - attrs.id = this.id; - - return attrs; - } - - createProperties() { - return this.getProperties(CREATE_ATTRIBUTES); - } - - fromPOJO(data) { - const dataClone = JSON.parse(JSON.stringify(data)); - - const persona = AiPersona.create(dataClone); - persona.tools = this.flattenedToolStructure(dataClone); - - return persona; - } - - toPOJO() { - const attrs = this.getProperties(CREATE_ATTRIBUTES); - this.populateTools(attrs); - attrs.forced_tool_count = this.forced_tool_count || -1; - attrs.response_format = attrs.response_format || []; - attrs.examples = attrs.examples || []; - - return attrs; - } -} diff --git a/assets/javascripts/discourse/admin/models/ai-tool.js b/assets/javascripts/discourse/admin/models/ai-tool.js deleted file mode 100644 index 4b9cdf7c..00000000 --- a/assets/javascripts/discourse/admin/models/ai-tool.js +++ /dev/null @@ -1,49 +0,0 @@ -import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins"; -import RestModel from "discourse/models/rest"; - -const CREATE_ATTRIBUTES = [ - "id", - "name", - "tool_name", - "description", - "parameters", - "script", - "summary", - "rag_uploads", - "rag_chunk_tokens", - "rag_chunk_overlap_tokens", - "rag_llm_model_id", - "enabled", -]; - -export default class AiTool extends RestModel { - createProperties() { - return this.getProperties(CREATE_ATTRIBUTES); - } - - updateProperties() { - return this.getProperties(CREATE_ATTRIBUTES); - } - - trackParameters(parameters) { - return new TrackedArray( - parameters?.map((p) => { - const parameter = new TrackedObject(p); - - if (parameter.enum && parameter.enum.length) { - parameter.enum = new TrackedArray(parameter.enum); - } else { - parameter.enum = null; - } - - return parameter; - }) - ); - } - - workingCopy() { - const attrs = this.getProperties(CREATE_ATTRIBUTES); - attrs.parameters = this.trackParameters(attrs.parameters); - return this.store.createRecord("ai-tool", attrs); - } -} diff --git a/assets/javascripts/discourse/components/admin-report-emotion.hbs b/assets/javascripts/discourse/components/admin-report-emotion.hbs deleted file mode 100644 index 08298b7c..00000000 --- a/assets/javascripts/discourse/components/admin-report-emotion.hbs +++ /dev/null @@ -1,45 +0,0 @@ -
- {{#if this.model.icon}} - {{d-icon this.model.icon}} - {{/if}} - {{this.model.title}} -
- - - -
- - {{number this.model.yesterdayCount}} - - {{d-icon this.model.yesterdayTrendIcon}} -
- -
- - {{number this.model.lastSevenDaysCount}} - - {{d-icon this.model.sevenDaysTrendIcon}} -
- -
- - - {{number this.model.lastThirtyDaysCount}} - - {{#if this.model.canDisplayTrendIcon}} - {{d-icon this.model.thirtyDaysTrendIcon}} - {{/if}} -
\ No newline at end of file diff --git a/assets/javascripts/discourse/components/admin-report-emotion.js b/assets/javascripts/discourse/components/admin-report-emotion.js deleted file mode 100644 index 7d7cccc1..00000000 --- a/assets/javascripts/discourse/components/admin-report-emotion.js +++ /dev/null @@ -1,39 +0,0 @@ -import Component from "@ember/component"; -import { attributeBindings, classNames } from "@ember-decorators/component"; -import getURL from "discourse/lib/get-url"; - -@classNames("admin-report-counters") -@attributeBindings("model.description:title") -export default class AdminReportEmotion extends Component { - get todayLink() { - let date = moment().format("YYYY-MM-DD"); - return this._filterURL(date); - } - - get yesterdayLink() { - let date = moment().subtract(1, "day").format("YYYY-MM-DD"); - return this._filterURL(date); - } - - get lastSevenDaysLink() { - let date = moment().subtract(1, "week").format("YYYY-MM-DD"); - return this._filterURL(date); - } - - get lastThirtyDaysLink() { - let date = moment().subtract(1, "month").format("YYYY-MM-DD"); - return this._filterURL(date); - } - - _baseFilter() { - return "/filter?q=activity-after%3A"; - } - - _model() { - return "%20order%3A" + this.model.type; - } - - _filterURL(date) { - return getURL(`${this._baseFilter()}${date}${this._model()}`); - } -} diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs deleted file mode 100644 index 2623f685..00000000 --- a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs +++ /dev/null @@ -1,452 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import { service } from "@ember/service"; -import { modifier } from "ember-modifier"; -import { and } from "truth-helpers"; -import DButton from "discourse/components/d-button"; -import HorizontalOverflowNav from "discourse/components/horizontal-overflow-nav"; -import PostList from "discourse/components/post-list"; -import bodyClass from "discourse/helpers/body-class"; -import categoryBadge from "discourse/helpers/category-badge"; -import dIcon from "discourse/helpers/d-icon"; -import replaceEmoji from "discourse/helpers/replace-emoji"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { getAbsoluteURL } from "discourse/lib/get-url"; -import discourseLater from "discourse/lib/later"; -import { clipboardCopy } from "discourse/lib/utilities"; -import Category from "discourse/models/category"; -import Post from "discourse/models/post"; -import { i18n } from "discourse-i18n"; -import DTooltip from "float-kit/components/d-tooltip"; -import DoughnutChart from "discourse/plugins/discourse-ai/discourse/components/doughnut-chart"; -import AiSentimentHorizontalBar from "../components/ai-sentiment-horizontal-bar"; - -export default class AdminReportSentimentAnalysis extends Component { - @service router; - - @tracked selectedChart = null; - @tracked posts = []; - @tracked hasMorePosts = false; - @tracked nextOffset = 0; - @tracked showingSelectedChart = false; - @tracked activeFilter = "all"; - @tracked shareIcon = "link"; - - setActiveFilter = modifier((element) => { - this.clearActiveFilters(element); - element - .querySelector(`li[data-filter-type="${this.activeFilter}"] button`) - .classList.add("active"); - }); - - clearActiveFilters(element) { - const filterButtons = element.querySelectorAll("li button"); - for (let button of filterButtons) { - button.classList.remove("active"); - } - } - - calculateNeutralScore(data) { - return data.total_count - (data.positive_count + data.negative_count); - } - - sentimentMapping(sentiment) { - switch (sentiment) { - case "positive": - return { - id: "positive", - text: i18n( - "discourse_ai.sentiments.sentiment_analysis.filter_types.positive" - ), - icon: "face-smile", - }; - case "neutral": - return { - id: "neutral", - text: i18n( - "discourse_ai.sentiments.sentiment_analysis.filter_types.neutral" - ), - icon: "face-meh", - }; - case "negative": - return { - id: "negative", - text: i18n( - "discourse_ai.sentiments.sentiment_analysis.filter_types.negative" - ), - icon: "face-angry", - }; - } - } - - get groupingType() { - const dataSample = this.args.model.data[0]; - const localePrefix = - "discourse_ai.sentiments.sentiment_analysis.group_types"; - return dataSample.category_name - ? i18n(`${localePrefix}.category`) - : i18n(`${localePrefix}.tag`); - } - - get colors() { - return ["#2ecc71", "#95a5a6", "#e74c3c"]; - } - - get currentGroupFilter() { - return this.args.model.available_filters.find( - (filter) => filter.id === "group_by" - ).default; - } - - get currentSortFilter() { - return this.args.model.available_filters.find( - (filter) => filter.id === "sort_by" - ).default; - } - - get transformedData() { - return this.args.model.data.map((data) => { - return { - category: Category.findById(data.category_id), - title: data.category_name || data.tag_name, - scores: [ - data.positive_count, - this.calculateNeutralScore(data), - data.negative_count, - ], - score_map: { - positive: data.positive_count, - neutral: this.calculateNeutralScore(data), - negative: data.negative_count, - }, - total_score: data.total_count, - widths: { - positive: (data.positive_count / data.total_count) * 100, - neutral: (this.calculateNeutralScore(data) / data.total_count) * 100, - negative: (data.negative_count / data.total_count) * 100, - }, - }; - }); - } - - get filteredPosts() { - if (!this.posts || !this.posts.length) { - return []; - } - - return this.posts.filter((post) => { - post.topic_title = replaceEmoji(post.topic_title); - post.category = Category.findById(post.category_id); - - if (this.activeFilter === "all") { - return true; - } - return post.sentiment === this.activeFilter; - }); - } - - get postFilters() { - return [ - { - id: "all", - text: `${i18n( - "discourse_ai.sentiments.sentiment_analysis.filter_types.all" - )} (${this.selectedChart.total_score})`, - icon: "bars-staggered", - action: () => { - this.activeFilter = "all"; - }, - }, - { - id: "positive", - text: `${i18n( - "discourse_ai.sentiments.sentiment_analysis.filter_types.positive" - )} (${this.selectedChart.scores[0]})`, - icon: "face-smile", - action: () => { - this.activeFilter = "positive"; - }, - }, - { - id: "neutral", - text: `${i18n( - "discourse_ai.sentiments.sentiment_analysis.filter_types.neutral" - )} (${this.selectedChart.scores[1]})`, - icon: "face-meh", - action: () => { - this.activeFilter = "neutral"; - }, - }, - { - id: "negative", - text: `${i18n( - "discourse_ai.sentiments.sentiment_analysis.filter_types.negative" - )} (${this.selectedChart.scores[2]})`, - icon: "face-angry", - action: () => { - this.activeFilter = "negative"; - }, - }, - ]; - } - - async postRequest() { - return await ajax("/discourse-ai/sentiment/posts", { - data: { - group_by: this.currentGroupFilter, - group_value: this.selectedChart?.title, - start_date: this.args.model.start_date, - end_date: this.args.model.end_date, - offset: this.nextOffset, - }, - }); - } - - @action - async openToChart() { - const queryParams = this.router.currentRoute.queryParams; - if (queryParams.selectedChart) { - this.selectedChart = this.transformedData.find( - (data) => data.title === queryParams.selectedChart - ); - - if (!this.selectedChart) { - return; - } - this.showingSelectedChart = true; - - try { - const response = await this.postRequest(); - this.posts = response.posts.map((post) => Post.create(post)); - this.hasMorePosts = response.has_more; - this.nextOffset = response.next_offset; - } catch (e) { - popupAjaxError(e); - } - } - } - - @action - async showDetails(data) { - if (this.selectedChart === data) { - // Don't do anything if the same chart is clicked again - return; - } - - const currentQueryParams = this.router.currentRoute.queryParams; - - const currentFilters = currentQueryParams?.filters - ? JSON.parse(currentQueryParams.filters) - : {}; - - this.router.transitionTo(this.router.currentRoute.name, { - queryParams: { - ...currentQueryParams, - filters: currentFilters, // avoids a double escaping - selectedChart: data.title, - }, - }); - - this.selectedChart = data; - this.showingSelectedChart = true; - - try { - const response = await this.postRequest(); - this.posts = response.posts.map((post) => Post.create(post)); - this.hasMorePosts = response.has_more; - this.nextOffset = response.next_offset; - } catch (e) { - popupAjaxError(e); - } - } - - @action - async fetchMorePosts() { - if (!this.hasMorePosts || this.selectedChart === null) { - return []; - } - - try { - const response = await this.postRequest(); - - this.hasMorePosts = response.has_more; - this.nextOffset = response.next_offset; - - const mappedPosts = response.posts.map((post) => Post.create(post)); - this.posts.pushObjects(mappedPosts); - return mappedPosts; - } catch (e) { - popupAjaxError(e); - } - } - - @action - backToAllCharts() { - this.showingSelectedChart = false; - this.selectedChart = null; - this.activeFilter = "all"; - this.posts = []; - - const currentQueryParams = this.router.currentRoute.queryParams; - const currentFilters = currentQueryParams?.filters - ? JSON.parse(currentQueryParams.filters) - : {}; - this.router.transitionTo(this.router.currentRoute.name, { - queryParams: { - ...currentQueryParams, - filters: currentFilters, // avoids a double escaping - selectedChart: null, - }, - }); - } - - @action - shareChart() { - const url = this.router.currentURL; - if (!url) { - return; - } - - clipboardCopy(getAbsoluteURL(url)); - this.shareIcon = "check"; - - discourseLater(() => { - this.shareIcon = "link"; - }, 2000); - } - - -} diff --git a/assets/javascripts/discourse/components/ai-artifact.gjs b/assets/javascripts/discourse/components/ai-artifact.gjs deleted file mode 100644 index fc7a0e0b..00000000 --- a/assets/javascripts/discourse/components/ai-artifact.gjs +++ /dev/null @@ -1,188 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import { service } from "@ember/service"; -import { htmlSafe } from "@ember/template"; -import DButton from "discourse/components/d-button"; -import htmlClass from "discourse/helpers/html-class"; -import getURL from "discourse/lib/get-url"; - -// note the panel for artifact full screen can not be at position 0,0 -// otherwise this hack will not activate. -// https://github.com/discourse/discourse/blob/b8325f2190a8c0a9022405c219faeac6f0f98ca5/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js#L77-L77 -// this will cause post stream to navigate to a different post - -export default class AiArtifactComponent extends Component { - @service siteSettings; - - @tracked expanded = false; - @tracked showingArtifact = false; - - constructor() { - super(...arguments); - this.keydownHandler = this.handleKeydown.bind(this); - this.popStateHandler = this.handlePopState.bind(this); - window.addEventListener("popstate", this.popStateHandler); - } - - willDestroy() { - super.willDestroy(...arguments); - window.removeEventListener("keydown", this.keydownHandler); - window.removeEventListener("popstate", this.popStateHandler); - } - - @action - handleKeydown(event) { - if (event.key === "Escape" || event.key === "Esc") { - history.back(); - } - } - - @action - handlePopState(event) { - const state = event.state; - this.expanded = state?.artifactId === this.args.artifactId; - if (!this.expanded) { - window.removeEventListener("keydown", this.keydownHandler); - } - } - - get requireClickToRun() { - if (this.showingArtifact) { - return false; - } - - if (this.siteSettings.ai_artifact_security === "strict") { - return true; - } - - if (this.siteSettings.ai_artifact_security === "hybrid") { - const shouldAutorun = - this.args.autorun === "true" || - this.args.autorun === true || - this.args.autorun === "1"; - - return !shouldAutorun; - } - - return this.siteSettings.ai_artifact_security !== "lax"; - } - - get artifactUrl() { - let url = getURL(`/discourse-ai/ai-bot/artifacts/${this.args.artifactId}`); - - if (this.args.artifactVersion) { - url = `${url}/${this.args.artifactVersion}`; - } - return url; - } - - @action - showArtifact() { - this.showingArtifact = true; - } - - @action - toggleView() { - if (!this.expanded) { - window.history.pushState( - { artifactId: this.args.artifactId }, - "", - window.location.href + "#artifact-fullscreen" - ); - window.addEventListener("keydown", this.keydownHandler); - } else { - history.back(); - } - this.expanded = !this.expanded; - } - - get wrapperClasses() { - return `ai-artifact__wrapper ${ - this.expanded ? "ai-artifact__expanded" : "" - } ${this.seamless ? "ai-artifact__seamless" : ""}`; - } - - @action - setDataAttributes(element) { - if (this.args.dataAttributes) { - Object.entries(this.args.dataAttributes).forEach(([key, value]) => { - element.setAttribute(key, value); - }); - } - } - - get heightStyle() { - if (this.args.artifactHeight) { - let height = parseInt(this.args.artifactHeight, 10); - if (isNaN(height) || height <= 0) { - height = 500; // default height if the provided value is invalid - } - - if (height > 2000) { - height = 2000; // cap the height to a maximum of 2000px - } - - return htmlSafe(`height: ${height}px;`); - } - } - - get seamless() { - return ( - this.args.seamless === "true" || - this.args.seamless === true || - this.args.seamless === "1" - ); - } - - get showFooter() { - return !this.seamless && !this.requireClickToRun; - } - - -} diff --git a/assets/javascripts/discourse/components/ai-blinking-animation.gjs b/assets/javascripts/discourse/components/ai-blinking-animation.gjs deleted file mode 100644 index 5b9ed1d2..00000000 --- a/assets/javascripts/discourse/components/ai-blinking-animation.gjs +++ /dev/null @@ -1,115 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import didUpdate from "@ember/render-modifiers/modifiers/did-update"; -import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; -import { cancel } from "@ember/runloop"; -import concatClass from "discourse/helpers/concat-class"; -import discourseLater from "discourse/lib/later"; - -class Block { - @tracked show = false; - @tracked shown = false; - @tracked blinking = false; - - constructor(args = {}) { - this.show = args.show ?? false; - this.shown = args.shown ?? false; - } -} - -const BLOCKS_SIZE = 20; // changing this requires to change css accordingly - -export default class AiBlinkingAnimation extends Component { - blocks = [...Array.from({ length: BLOCKS_SIZE }, () => new Block())]; - - #nextBlockBlinkingTimer; - #blockBlinkingTimer; - #blockShownTimer; - - @action - setupAnimation() { - this.blocks.firstObject.show = true; - this.blocks.firstObject.shown = true; - } - - @action - onBlinking(block) { - if (!block.blinking) { - return; - } - - block.show = false; - - this.#nextBlockBlinkingTimer = discourseLater( - this, - () => { - this.#nextBlock(block).blinking = true; - }, - 250 - ); - - this.#blockBlinkingTimer = discourseLater( - this, - () => { - block.blinking = false; - }, - 500 - ); - } - - @action - onShowing(block) { - if (!block.show) { - return; - } - - this.#blockShownTimer = discourseLater( - this, - () => { - this.#nextBlock(block).show = true; - this.#nextBlock(block).shown = true; - - if (this.blocks.lastObject === block) { - this.blocks.firstObject.blinking = true; - } - }, - 250 - ); - } - - @action - teardownAnimation() { - cancel(this.#blockShownTimer); - cancel(this.#nextBlockBlinkingTimer); - cancel(this.#blockBlinkingTimer); - } - - #nextBlock(currentBlock) { - if (currentBlock === this.blocks.lastObject) { - return this.blocks.firstObject; - } else { - return this.blocks.objectAt(this.blocks.indexOf(currentBlock) + 1); - } - } - - -} diff --git a/assets/javascripts/discourse/components/ai-bot-conversations.gjs b/assets/javascripts/discourse/components/ai-bot-conversations.gjs deleted file mode 100644 index 462a8452..00000000 --- a/assets/javascripts/discourse/components/ai-bot-conversations.gjs +++ /dev/null @@ -1,381 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import Component from "@ember/component"; -import { fn, hash } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { getOwner } from "@ember/owner"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import { scheduleOnce } from "@ember/runloop"; -import { service } from "@ember/service"; -import { TrackedArray } from "@ember-compat/tracked-built-ins"; -import $ from "jquery"; -import DButton from "discourse/components/d-button"; -import PluginOutlet from "discourse/components/plugin-outlet"; -import bodyClass from "discourse/helpers/body-class"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import userAutocomplete from "discourse/lib/autocomplete/user"; -import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete"; -import UppyUpload from "discourse/lib/uppy/uppy-upload"; -import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; -import userSearch from "discourse/lib/user-search"; -import { - destroyUserStatuses, - initUserStatusHtml, - renderUserStatusHtml, -} from "discourse/lib/user-status-on-autocomplete"; -import { clipboardHelpers } from "discourse/lib/utilities"; -import { i18n } from "discourse-i18n"; -import AiPersonaLlmSelector from "discourse/plugins/discourse-ai/discourse/components/ai-persona-llm-selector"; - -export default class AiBotConversations extends Component { - @service aiBotConversationsHiddenSubmit; - @service currentUser; - @service mediaOptimizationWorker; - @service site; - @service siteSettings; - - @tracked uploads = new TrackedArray(); - // Don't track this directly - we'll get it from uppyUpload - - textarea = null; - uppyUpload = null; - fileInputEl = null; - - _handlePaste = (event) => { - if (document.activeElement !== this.textarea) { - return; - } - - const { canUpload, canPasteHtml, types } = clipboardHelpers(event, { - siteSettings: this.siteSettings, - canUpload: true, - }); - - if (!canUpload || canPasteHtml || types.includes("text/plain")) { - return; - } - - if (event && event.clipboardData && event.clipboardData.files) { - this.uppyUpload.addFiles([...event.clipboardData.files], { - pasted: true, - }); - } - }; - - init() { - super.init(...arguments); - - this.uppyUpload = new UppyUpload(getOwner(this), { - id: "ai-bot-file-uploader", - type: "ai-bot-conversation", - useMultipartUploadsIfAvailable: true, - - uppyReady: () => { - if (this.siteSettings.composer_media_optimization_image_enabled) { - this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, { - optimizeFn: (data, opts) => - this.mediaOptimizationWorker.optimizeImage(data, opts), - runParallel: !this.site.isMobileDevice, - }); - } - - this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => { - const inProgressUpload = this.inProgressUploads?.find( - (upl) => upl.id === file.id - ); - if (inProgressUpload && !inProgressUpload.processing) { - inProgressUpload.processing = true; - } - }); - - this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => { - const inProgressUpload = this.inProgressUploads?.find( - (upl) => upl.id === file.id - ); - if (inProgressUpload) { - inProgressUpload.processing = false; - } - }); - - this.textarea?.addEventListener("paste", this._handlePaste); - }, - - uploadDone: (upload) => { - this.uploads.push(upload); - }, - - // Fix: Don't try to set inProgressUploads directly - onProgressUploadsChanged: () => { - // This is just for UI triggers - we're already tracking inProgressUploads - this.notifyPropertyChange("inProgressUploads"); - }, - }); - } - - willDestroy() { - super.willDestroy(...arguments); - this.textarea?.removeEventListener("paste", this._handlePaste); - this.uppyUpload?.teardown(); - // needed for safety (textarea may not have a autocomplete) - if (this.textarea.autocomplete) { - this.textarea.autocomplete("destroy"); - } - } - - get loading() { - return this.aiBotConversationsHiddenSubmit?.loading; - } - - get inProgressUploads() { - return this.uppyUpload?.inProgressUploads || []; - } - - get showUploadsContainer() { - return this.uploads?.length > 0 || this.inProgressUploads?.length > 0; - } - - @action - setPersonaId(id) { - this.aiBotConversationsHiddenSubmit.personaId = id; - } - - @action - setTargetRecipient(username) { - this.aiBotConversationsHiddenSubmit.targetUsername = username; - } - - @action - updateInputValue(value) { - this._autoExpandTextarea(); - this.aiBotConversationsHiddenSubmit.inputValue = - value.target?.value || value; - } - - @action - handleKeyDown(event) { - if (event.target.tagName !== "TEXTAREA") { - return; - } - if (event.key === "Enter" && !event.shiftKey) { - this.prepareAndSubmitToBot(); - } - } - - @action - setTextArea(element) { - this.textarea = element; - this.setupAutocomplete(element); - scheduleOnce("afterRender", this, this.focusTextarea); - } - - @action - focusTextarea() { - this.textarea?.focus(); - } - - @action - setupAutocomplete(textarea) { - const $textarea = $(textarea); - this.applyUserAutocomplete($textarea); - this.applyHashtagAutocomplete($textarea); - } - - @action - applyUserAutocomplete($textarea) { - if (!this.siteSettings.enable_mentions) { - return; - } - - $textarea.autocomplete({ - template: userAutocomplete, - dataSource: (term) => { - destroyUserStatuses(); - return userSearch({ - term, - includeGroups: true, - }).then((result) => { - initUserStatusHtml(getOwner(this), result.users); - return result; - }); - }, - onRender: (options) => renderUserStatusHtml(options), - key: "@", - width: "100%", - treatAsTextarea: true, - autoSelectFirstSuggestion: true, - transformComplete: (obj) => obj.username || obj.name, - afterComplete: (text) => { - this.textarea.value = text; - this.focusTextarea(); - this.updateInputValue({ target: { value: text } }); - }, - onClose: destroyUserStatuses, - }); - } - - @action - applyHashtagAutocomplete($textarea) { - // Use the "topic-composer" configuration or create a specific one for AI bot - // You can change this to "chat-composer" if that's more appropriate - const hashtagConfig = this.site.hashtag_configurations["topic-composer"]; - - setupHashtagAutocomplete(hashtagConfig, $textarea, this.siteSettings, { - treatAsTextarea: true, - afterComplete: (text) => { - this.textarea.value = text; - this.focusTextarea(); - this.updateInputValue({ target: { value: text } }); - }, - }); - } - - @action - registerFileInput(element) { - if (element) { - this.fileInputEl = element; - if (this.uppyUpload) { - this.uppyUpload.setup(element); - } - } - } - - @action - openFileUpload() { - if (this.fileInputEl) { - this.fileInputEl.click(); - } - } - - @action - removeUpload(upload) { - this.uploads = new TrackedArray(this.uploads.filter((u) => u !== upload)); - } - - @action - cancelUpload(upload) { - this.uppyUpload.cancelSingleUpload({ - fileId: upload.id, - }); - } - - @action - async prepareAndSubmitToBot() { - try { - await this.aiBotConversationsHiddenSubmit.submitToBot({ - uploads: this.uploads, - inProgressUploadsCount: this.inProgressUploads.length, - }); - this.uploads = new TrackedArray(); - } catch (error) { - popupAjaxError(error); - } - } - - _autoExpandTextarea() { - this.textarea.style.height = "auto"; - this.textarea.style.height = this.textarea.scrollHeight + "px"; - - // Get the max-height value from CSS (30vh) - const maxHeight = parseInt(getComputedStyle(this.textarea).maxHeight, 10); - - // Only enable scrolling if content exceeds max-height - if (this.textarea.scrollHeight > maxHeight) { - this.textarea.style.overflowY = "auto"; - } else { - this.textarea.style.overflowY = "hidden"; - } - } - - -} diff --git a/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs b/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs deleted file mode 100644 index c851f103..00000000 --- a/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs +++ /dev/null @@ -1,169 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import categoryBadge from "discourse/helpers/category-badge"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import DMenu from "float-kit/components/d-menu"; -import eq from "truth-helpers/helpers/eq"; - -export default class AiSplitTopicSuggester extends Component { - @service site; - @service menu; - - @tracked suggestions = []; - @tracked loading = false; - @tracked icon = "discourse-sparkles"; - - SUGGESTION_TYPES = { - title: "suggest_title", - category: "suggest_category", - tag: "suggest_tags", - }; - - get input() { - return this.args.selectedPosts.map((item) => item.cooked).join("\n"); - } - - get disabled() { - return this.loading || this.suggestions.length > 0; - } - - @action - loadSuggestions() { - if (this.loading || this.suggestions.length > 0) { - return; - } - - this.loading = true; - - ajax(`/discourse-ai/ai-helper/${this.args.mode}`, { - method: "POST", - data: { text: this.input }, - }) - .then((result) => { - if (this.args.mode === this.SUGGESTION_TYPES.title) { - this.suggestions = result.suggestions; - } else if (this.args.mode === this.SUGGESTION_TYPES.category) { - const suggestionIds = result.assistant.map((s) => s.id); - const suggestedCategories = this.site.categories.filter((category) => - suggestionIds.includes(category.id) - ); - this.suggestions = suggestedCategories; - } else if (this.args.mode === this.SUGGESTION_TYPES.tag) { - this.suggestions = result.assistant.map((s) => { - return { - name: s.name, - count: s.count, - }; - }); - } - }) - .catch(popupAjaxError) - .finally(() => { - this.loading = false; - }); - } - - @action - applySuggestion(suggestion, menu) { - if (!this.args.mode) { - return; - } - - if (this.args.mode === this.SUGGESTION_TYPES.title) { - this.args.updateAction(suggestion); - return menu.close(); - } - - if (this.args.mode === this.SUGGESTION_TYPES.category) { - this.args.updateAction(suggestion.id); - return menu.close(); - } - - if (this.args.mode === this.SUGGESTION_TYPES.tag) { - if (this.args.currentValue) { - if (Array.isArray(this.args.currentValue)) { - const updatedTags = [...this.args.currentValue, suggestion]; - this.args.updateAction([...new Set(updatedTags)]); - } else { - const updatedTags = [this.args.currentValue, suggestion]; - this.args.updateAction([...new Set(updatedTags)]); - } - } else { - if (Array.isArray(suggestion)) { - this.args.updateAction([...suggestion]); - } else { - this.args.updateAction([suggestion]); - } - } - return menu.close(); - } - } - - -} diff --git a/assets/javascripts/discourse/components/ai-summary-skeleton.gjs b/assets/javascripts/discourse/components/ai-summary-skeleton.gjs deleted file mode 100644 index dc5f3207..00000000 --- a/assets/javascripts/discourse/components/ai-summary-skeleton.gjs +++ /dev/null @@ -1,18 +0,0 @@ -import { i18n } from "discourse-i18n"; -import AiBlinkingAnimation from "./ai-blinking-animation"; -import AiIndicatorWave from "./ai-indicator-wave"; - -const AiSummarySkeleton = ; - -export default AiSummarySkeleton; diff --git a/assets/javascripts/discourse/components/ai-tool-editor-form.gjs b/assets/javascripts/discourse/components/ai-tool-editor-form.gjs deleted file mode 100644 index aa3298a4..00000000 --- a/assets/javascripts/discourse/components/ai-tool-editor-form.gjs +++ /dev/null @@ -1,416 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { concat, fn, hash } from "@ember/helper"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import { and, gt } from "truth-helpers"; -import Form from "discourse/components/form"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import getURL from "discourse/lib/get-url"; -import { i18n } from "discourse-i18n"; -import AiToolTestModal from "./modal/ai-tool-test-modal"; -import RagOptionsFk from "./rag-options-fk"; -import RagUploader from "./rag-uploader"; - -export default class AiToolEditorForm extends Component { - @service modal; - @service siteSettings; - @service dialog; - @service router; - @service toasts; - - @tracked uploadedFiles = []; - @tracked isSaving = false; - - PARAMETER_TYPES = [ - { name: "string", id: "string" }, - { name: "number", id: "number" }, - { name: "boolean", id: "boolean" }, - { name: "array", id: "array" }, - ]; - - get formData() { - const parameters = (this.args.editingModel.parameters ?? []).map( - (parameter) => { - const mappedParameter = { - ...parameter, - }; - mappedParameter.isEnum = parameter.enum && parameter.enum.length > 0; - if (!mappedParameter.isEnum) { - delete mappedParameter.enum; - } - return mappedParameter; - } - ); - - return { - name: this.args.editingModel.name || "", - tool_name: this.args.editingModel.tool_name || "", - description: this.args.editingModel.description || "", - summary: this.args.editingModel.summary || "", - parameters, - script: this.args.editingModel.script || "", - rag_uploads: this.args.editingModel.rag_uploads || [], - }; - } - - @action - toggleIsEnum(value, { name, parentName, set }) { - if (value) { - set(`${parentName}.enum`, [""]); - } else { - set(`${parentName}.enum`, []); - } - - set(name, value); - } - - @action - async save(data) { - this.isSaving = true; - - // we injected a isEnum thing, we need to clean it up - const copiedData = JSON.parse(JSON.stringify(data)); - if (copiedData.parameters) { - copiedData.parameters.forEach((parameter) => { - if (!parameter.isEnum) { - delete parameter.enum; - } - delete parameter.isEnum; - }); - } - - try { - await this.args.model.save(copiedData); - - this.toasts.success({ - data: { message: i18n("discourse_ai.tools.saved") }, - duration: "short", - }); - - if (!this.args.tools.any((tool) => tool.id === this.args.model.id)) { - this.args.tools.pushObject(this.args.model); - } - - await this.router.replaceWith( - "adminPlugins.show.discourse-ai-tools.edit", - this.args.model - ); - } catch (e) { - popupAjaxError(e); - } finally { - this.isSaving = false; - } - } - - @action - delete() { - return this.dialog.confirm({ - message: i18n("discourse_ai.tools.confirm_delete"), - - didConfirm: async () => { - await this.args.model.destroyRecord(); - this.args.tools.removeObject(this.args.model); - this.router.transitionTo("adminPlugins.show.discourse-ai-tools.index"); - }, - }); - } - - @action - updateUploads(addItemToCollection, uploads) { - const uniqueUploads = uploads.filter( - (upload) => !this.uploadedFiles.some((file) => file.id === upload.id) - ); - addItemToCollection("rag_uploads", uniqueUploads); - this.uploadedFiles = [...this.uploadedFiles, ...uniqueUploads]; - } - - @action - removeUpload(form, upload) { - this.uploadedFiles = this.uploadedFiles.filter( - (file) => file.id !== upload.id - ); - form.set("rag_uploads", this.uploadedFiles); - } - - @action - openTestModal() { - this.modal.show(AiToolTestModal, { - model: { - tool: this.args.editingModel, - }, - }); - } - - currentParameterSelection(data, index) { - return data.parameters[index].type; - } - - get ragUploadsDescription() { - return this.siteSettings.rag_images_enabled - ? i18n("discourse_ai.rag.uploads.description_with_images") - : i18n("discourse_ai.rag.uploads.description"); - } - - @action - exportTool() { - const exportUrl = `/admin/plugins/discourse-ai/ai-tools/${this.args.model.id}/export.json`; - window.location.href = getURL(exportUrl); - } - - -} diff --git a/assets/javascripts/discourse/components/ai-tool-editor.gjs b/assets/javascripts/discourse/components/ai-tool-editor.gjs deleted file mode 100644 index 98fffe5b..00000000 --- a/assets/javascripts/discourse/components/ai-tool-editor.gjs +++ /dev/null @@ -1,39 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import BackButton from "discourse/components/back-button"; -import AiToolEditorForm from "./ai-tool-editor-form"; - -export default class AiToolEditor extends Component { - @service store; - - get selectedPreset() { - if (!this.args.selectedPreset) { - return this.args.presets.findBy("preset_id", "empty_tool"); - } - - return this.args.presets.findBy("preset_id", this.args.selectedPreset); - } - - get editingModel() { - if (this.args.model.isNew) { - return this.store.createRecord("ai-tool", this.selectedPreset); - } else { - return this.args.model; - } - } - - -} diff --git a/assets/javascripts/discourse/components/ai-tool-list-editor.gjs b/assets/javascripts/discourse/components/ai-tool-list-editor.gjs deleted file mode 100644 index 6012401a..00000000 --- a/assets/javascripts/discourse/components/ai-tool-list-editor.gjs +++ /dev/null @@ -1,200 +0,0 @@ -import Component from "@glimmer/component"; -import { fn } from "@ember/helper"; -import { action } from "@ember/object"; -import { LinkTo } from "@ember/routing"; -import { service } from "@ember/service"; -import { eq } from "truth-helpers"; -import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item"; -import DButton from "discourse/components/d-button"; -import DPageSubheader from "discourse/components/d-page-subheader"; -import DropdownMenu from "discourse/components/dropdown-menu"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { i18n } from "discourse-i18n"; -import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list"; -import DMenu from "float-kit/components/d-menu"; -import AiTool from "../admin/models/ai-tool"; - -export default class AiToolListEditor extends Component { - @service adminPluginNavManager; - @service router; - @service dialog; - - get lastIndexOfPresets() { - return this.args.tools.resultSetMeta.presets.length - 1; - } - - @action - routeToNewTool(preset) { - return this.router.transitionTo( - "adminPlugins.show.discourse-ai-tools.new", - { - queryParams: { - presetId: preset.preset_id, - }, - } - ); - } - - @action - importTool() { - // Create a hidden file input and click it - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".json"; - fileInput.style.display = "none"; - fileInput.onchange = (event) => this.handleFileSelect(event); - document.body.appendChild(fileInput); - fileInput.click(); - document.body.removeChild(fileInput); - } - - @action - handleFileSelect(event) { - const file = event.target.files[0]; - if (!file) { - return; - } - - const reader = new FileReader(); - reader.onload = (e) => { - try { - const json = JSON.parse(e.target.result); - this.uploadTool(json.ai_tool); - } catch { - this.dialog.alert(i18n("discourse_ai.tools.import_error_not_json")); - } - }; - reader.readAsText(file); - } - - uploadTool(toolData, force = false) { - let url = `/admin/plugins/discourse-ai/ai-tools/import.json`; - const payload = { - ai_tool: toolData, - }; - if (force) { - payload.force = true; - } - - return ajax(url, { - type: "POST", - data: JSON.stringify(payload), - contentType: "application/json", - }) - .then((result) => { - let tool = AiTool.create(result.ai_tool); - let existingTool = this.args.tools.findBy("id", tool.id); - if (existingTool) { - this.args.tools.removeObject(existingTool); - } - this.args.tools.insertAt(0, tool); - }) - .catch((error) => { - if (error.jqXHR?.status === 409) { - this.dialog.confirm({ - message: i18n("discourse_ai.tools.import_error_conflict", { - name: toolData.tool_name, - }), - confirmButtonLabel: "discourse_ai.tools.overwrite", - didConfirm: () => this.uploadTool(toolData, true), - }); - } else { - popupAjaxError(error); - } - }); - } - - -} diff --git a/assets/javascripts/discourse/components/ai-tool-selector.gjs b/assets/javascripts/discourse/components/ai-tool-selector.gjs deleted file mode 100644 index c4509d1b..00000000 --- a/assets/javascripts/discourse/components/ai-tool-selector.gjs +++ /dev/null @@ -1,13 +0,0 @@ -import { hash } from "@ember/helper"; -import MultiSelect from "select-kit/components/multi-select"; - -const AiToolSelector = ; - -export default AiToolSelector; diff --git a/assets/javascripts/discourse/components/ai-topic-gist.gjs b/assets/javascripts/discourse/components/ai-topic-gist.gjs deleted file mode 100644 index c2fed1a4..00000000 --- a/assets/javascripts/discourse/components/ai-topic-gist.gjs +++ /dev/null @@ -1,41 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import { htmlSafe } from "@ember/template"; - -export default class AiTopicGist extends Component { - @service gists; - - get shouldShow() { - return this.gists.preference === "table-ai" && this.gists.shouldShow; - } - - get hasGist() { - return !!this.gist; - } - - get gist() { - return this.args.topic.get("ai_topic_gist"); - } - - get escapedExcerpt() { - return this.args.topic.get("escapedExcerpt"); - } - - -} diff --git a/assets/javascripts/discourse/components/ai-usage.gjs b/assets/javascripts/discourse/components/ai-usage.gjs deleted file mode 100644 index 0aaac6b2..00000000 --- a/assets/javascripts/discourse/components/ai-usage.gjs +++ /dev/null @@ -1,648 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn, hash } from "@ember/helper"; -import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import didUpdate from "@ember/render-modifiers/modifiers/did-update"; -import { LinkTo } from "@ember/routing"; -import { service } from "@ember/service"; -import { eq, gt, lt } from "truth-helpers"; -import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; -import DButton from "discourse/components/d-button"; -import DPageSubheader from "discourse/components/d-page-subheader"; -import DStatTiles from "discourse/components/d-stat-tiles"; -import DateTimeInputRange from "discourse/components/date-time-input-range"; -import avatar from "discourse/helpers/avatar"; -import concatClass from "discourse/helpers/concat-class"; -import { ajax } from "discourse/lib/ajax"; -import { bind } from "discourse/lib/decorators"; -import { number } from "discourse/lib/formatter"; -import { i18n } from "discourse-i18n"; -import AdminConfigAreaCard from "admin/components/admin-config-area-card"; -import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list"; -import Chart from "admin/components/chart"; -import ComboBox from "select-kit/components/combo-box"; - -export default class AiUsage extends Component { - @service store; - - @tracked startDate = moment().subtract(30, "days").toDate(); - @tracked endDate = new Date(); - @tracked data = this.args.model; - @tracked selectedFeature; - @tracked selectedModel; - @tracked selectedPeriod = "month"; - @tracked isCustomDateActive = false; - @tracked loadingData = true; - - constructor() { - super(...arguments); - this.fetchData(); - } - - @action - async fetchData() { - const response = await ajax( - "/admin/plugins/discourse-ai/ai-usage-report.json", - { - data: { - start_date: moment(this.startDate).format("YYYY-MM-DD"), - end_date: moment(this.endDate).format("YYYY-MM-DD"), - feature: this.selectedFeature, - model: this.selectedModel, - }, - } - ); - this.data = response; - this.loadingData = false; - this._cachedFeatures = null; - this._cachedModels = null; - } - - @action - async onFilterChange() { - await this.fetchData(); - } - - @action - onFeatureChanged(value) { - this.selectedFeature = value; - this.onFilterChange(); - } - - @action - onModelChanged(value) { - this.selectedModel = value; - this.onFilterChange(); - } - - @action - addCurrencyChar(element) { - element.querySelectorAll(".d-stat-tile__label").forEach((label) => { - if ( - label.innerText.trim() === i18n("discourse_ai.usage.total_spending") - ) { - const valueElement = label - .closest(".d-stat-tile") - ?.querySelector(".d-stat-tile__value"); - if (valueElement) { - valueElement.innerText = `$${valueElement.innerText}`; - } - } - }); - } - - @bind - takeUsers(start, end) { - return this.data.users.slice(start, end); - } - - normalizeTimeSeriesData(data) { - if (!data?.length) { - return []; - } - - const startDate = moment(this.startDate); - const endDate = moment(this.endDate); - const normalized = []; - let interval; - let format; - - if (this.data.period === "hour") { - interval = "hour"; - format = "YYYY-MM-DD HH:00:00"; - } else if (this.data.period === "day") { - interval = "day"; - format = "YYYY-MM-DD"; - } else { - interval = "month"; - format = "YYYY-MM"; - } - const dataMap = new Map( - data.map((d) => [moment(d.period).format(format), d]) - ); - - for ( - let currentMoment = moment(startDate); - currentMoment.isSameOrBefore(endDate); - currentMoment.add(1, interval) - ) { - const dateKey = currentMoment.format(format); - const existingData = dataMap.get(dateKey); - - normalized.push( - existingData || { - period: currentMoment.format(), - total_tokens: 0, - total_cached_tokens: 0, - total_request_tokens: 0, - total_response_tokens: 0, - } - ); - } - - return normalized; - } - - get metrics() { - return [ - { - label: i18n("discourse_ai.usage.total_requests"), - value: this.data.summary.total_requests, - tooltip: i18n("discourse_ai.usage.stat_tooltips.total_requests"), - }, - { - label: i18n("discourse_ai.usage.total_tokens"), - value: this.data.summary.total_tokens, - tooltip: i18n("discourse_ai.usage.stat_tooltips.total_tokens"), - }, - { - label: i18n("discourse_ai.usage.request_tokens"), - value: this.data.summary.total_request_tokens, - tooltip: i18n("discourse_ai.usage.stat_tooltips.request_tokens"), - }, - { - label: i18n("discourse_ai.usage.response_tokens"), - value: this.data.summary.total_response_tokens, - tooltip: i18n("discourse_ai.usage.stat_tooltips.response_tokens"), - }, - { - label: i18n("discourse_ai.usage.cached_tokens"), - value: this.data.summary.total_cached_tokens, - tooltip: i18n("discourse_ai.usage.stat_tooltips.cached_tokens"), - }, - { - label: i18n("discourse_ai.usage.total_spending"), - value: this.data.summary.total_spending, - tooltip: i18n("discourse_ai.usage.stat_tooltips.total_spending"), - }, - ]; - } - - get chartConfig() { - if (!this.data?.data) { - return; - } - - const normalizedData = this.normalizeTimeSeriesData(this.data.data); - - const chartEl = document.querySelector(".ai-usage__chart"); - const computedStyle = getComputedStyle(chartEl); - - const colors = { - response: computedStyle.getPropertyValue("--chart-response-color").trim(), - request: computedStyle.getPropertyValue("--chart-request-color").trim(), - cached: computedStyle.getPropertyValue("--chart-cached-color").trim(), - }; - - return { - type: "bar", - data: { - labels: normalizedData.map((row) => { - const date = moment(row.period); - if (this.data.period === "hour") { - return date.format("HH:00"); - } else if (this.data.period === "day") { - return date.format("DD-MMM"); - } else { - return date.format("MMM-YY"); - } - }), - datasets: [ - { - label: i18n("discourse_ai.usage.response_tokens"), - data: normalizedData.map((row) => row.total_response_tokens), - backgroundColor: colors.response, - }, - { - label: i18n("discourse_ai.usage.net_request_tokens"), - data: normalizedData.map( - (row) => row.total_request_tokens - row.total_cached_tokens - ), - backgroundColor: colors.request, - }, - { - label: i18n("discourse_ai.usage.cached_request_tokens"), - data: normalizedData.map((row) => row.total_cached_tokens), - backgroundColor: colors.cached, - }, - ], - }, - options: { - responsive: true, - scales: { - x: { - stacked: true, - }, - y: { - stacked: true, - beginAtZero: true, - }, - }, - }, - }; - } - - get availableFeatures() { - // when you switch we don't want the list to change - // only when you switch durations - this._cachedFeatures = - this._cachedFeatures || - (this.data?.features || []).map((f) => ({ - id: f.feature_name, - name: f.feature_name, - })); - - return this._cachedFeatures; - } - - get availableModels() { - this._cachedModels = - this._cachedModels || - (this.data?.models || []).map((m) => ({ - id: m.llm, - name: m.llm, - })); - - return this._cachedModels; - } - - get periodOptions() { - return [ - { id: "day", name: i18n("discourse_ai.usage.periods.last_day") }, - { id: "week", name: i18n("discourse_ai.usage.periods.last_week") }, - { id: "month", name: i18n("discourse_ai.usage.periods.last_month") }, - ]; - } - - @action - setPeriodDates(period) { - const now = moment(); - - switch (period) { - case "day": - this.startDate = now.clone().subtract(1, "day").toDate(); - this.endDate = now.toDate(); - break; - case "week": - this.startDate = now.clone().subtract(7, "days").toDate(); - this.endDate = now.toDate(); - break; - case "month": - this.startDate = now.clone().subtract(30, "days").toDate(); - this.endDate = now.toDate(); - break; - } - } - - @action - onPeriodSelect(period) { - this.selectedPeriod = period; - this.isCustomDateActive = false; - this.setPeriodDates(period); - this.fetchData(); - } - - @action - onCustomDateClick() { - this.isCustomDateActive = !this.isCustomDateActive; - if (this.isCustomDateActive) { - this.selectedPeriod = null; - } - } - - @action - onDateChange() { - this.isCustomDateActive = true; - this.selectedPeriod = null; - this.fetchData(); - } - - @action - onChangeDateRange({ from, to }) { - this._startDate = from; - this._endDate = to; - } - - @action - onRefreshDateRange() { - this.startDate = this._startDate; - this.endDate = this._endDate; - this.fetchData(); - } - - totalSpending(inputSpending, cachedSpending, outputSpending) { - const total = inputSpending + cachedSpending + outputSpending; - return `$${total.toFixed(2)}`; - } - - -} diff --git a/assets/javascripts/discourse/components/doughnut-chart.gjs b/assets/javascripts/discourse/components/doughnut-chart.gjs deleted file mode 100644 index d0f0464e..00000000 --- a/assets/javascripts/discourse/components/doughnut-chart.gjs +++ /dev/null @@ -1,109 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import Chart from "admin/components/chart"; - -export default class DoughnutChart extends Component { - @tracked canvasSize = null; - - get config() { - const totalScore = this.args.totalScore || ""; - - return { - type: "doughnut", - data: { - labels: this.args.labels, - datasets: [ - { - data: this.args.data, - backgroundColor: this.args.colors, - cutout: "50%", - radius: 100, - }, - ], - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: this.args.displayLegend || false, - position: "bottom", - }, - }, - }, - plugins: [ - { - id: "centerText", - afterDraw: function (chart) { - const cssVarColor = - getComputedStyle(document.documentElement).getPropertyValue( - "--primary-high" - ) || "#000"; - const cssFontSize = - getComputedStyle(document.documentElement).getPropertyValue( - "--font-up-4" - ) || "1.3em"; - const cssFontFamily = - getComputedStyle(document.documentElement).getPropertyValue( - "--font-family" - ) || "sans-serif"; - - const { ctx, chartArea } = chart; - const centerX = (chartArea.left + chartArea.right) / 2; - const centerY = (chartArea.top + chartArea.bottom) / 2; - - ctx.restore(); - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = cssVarColor.trim(); - ctx.font = `bold ${cssFontSize.trim()} ${cssFontFamily.trim()}`; - - ctx.fillText(totalScore, centerX, centerY); - ctx.save(); - }, - }, - { - // Custom plugin to draw labels inside the doughnut chart - id: "doughnutLabels", - afterDraw(chart) { - const ctx = chart.ctx; - const dataset = chart.data.datasets[0]; - const meta = chart.getDatasetMeta(0); - const cssFontSize = - getComputedStyle(document.documentElement).getPropertyValue( - "--font-down-2" - ) || "1.3em"; - const cssFontFamily = - getComputedStyle(document.documentElement).getPropertyValue( - "--font-family" - ) || "sans-serif"; - - ctx.font = `${cssFontSize.trim()} ${cssFontFamily.trim()}`; - ctx.fillStyle = "#fafafa"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - meta.data.forEach((element, index) => { - const { x, y } = element.tooltipPosition(); - const value = dataset.data[index]; - const nonZeroCount = dataset.data.filter((v) => v > 0).length; - - if (value === 0 || nonZeroCount === 1) { - return; - } - - ctx.fillText(value, x, y); - }); - }, - }, - ], - }; - } - - -} diff --git a/assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs b/assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs deleted file mode 100644 index 3c82d88a..00000000 --- a/assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs +++ /dev/null @@ -1,144 +0,0 @@ -import Component from "@glimmer/component"; -import { cached } from "@glimmer/tracking"; -import { fn, hash } from "@ember/helper"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DModal from "discourse/components/d-modal"; -import Form from "discourse/components/form"; -import { i18n } from "discourse-i18n"; -import GroupChooser from "select-kit/components/group-chooser"; -import DurationSelector from "../ai-quota-duration-selector"; - -export default class AiLlmQuotaModal extends Component { - @service site; - - @action - save(data) { - const quota = { ...data }; - quota.group_name = this.site.groups.findBy("id", data.group_id).name; - quota.llm_model_id = this.args.model.id; - - this.args.model.addItemToCollection(quota); - this.args.closeModal(); - - if (this.args.model.onSave) { - this.args.model.onSave(); - } - } - - get availableGroups() { - const existingQuotaGroupIds = - this.args.model.llm.llm_quotas.map((q) => q.group_id) || []; - - return this.site.groups.filter( - (group) => !existingQuotaGroupIds.includes(group.id) && group.id !== 0 - ); - } - - @cached - get quota() { - return { - group_id: null, - llm_model_id: null, - max_tokens: null, - max_usages: null, - duration_seconds: moment.duration(1, "day").asSeconds(), - }; - } - - @action - setGroupId(field, groups) { - field.set(groups[0]); - } - - @action - validateForm(data, { addError, removeError }) { - if (!data.max_tokens && !data.max_usages) { - addError("max_tokens", { - title: i18n("discourse_ai.llms.quotas.max_tokens"), - message: i18n("discourse_ai.llms.quotas.max_tokens_required"), - }); - addError("max_usages", { - title: i18n("discourse_ai.llms.quotas.max_usages"), - message: i18n("discourse_ai.llms.quotas.max_usages_required"), - }); - } else { - removeError("max_tokens"); - removeError("max_usages"); - } - } - - -} diff --git a/assets/javascripts/discourse/components/modal/ai-persona-response-format-editor.gjs b/assets/javascripts/discourse/components/modal/ai-persona-response-format-editor.gjs deleted file mode 100644 index af7ff137..00000000 --- a/assets/javascripts/discourse/components/modal/ai-persona-response-format-editor.gjs +++ /dev/null @@ -1,113 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn, hash } from "@ember/helper"; -import { action } from "@ember/object"; -import { gt } from "truth-helpers"; -import ModalJsonSchemaEditor from "discourse/components/modal/json-schema-editor"; -import { prettyJSON } from "discourse/lib/formatter"; -import { i18n } from "discourse-i18n"; - -export default class AiPersonaResponseFormatEditor extends Component { - @tracked showJsonEditorModal = false; - - jsonSchema = { - type: "array", - uniqueItems: true, - title: i18n("discourse_ai.ai_persona.response_format.modal.root_title"), - items: { - type: "object", - title: i18n("discourse_ai.ai_persona.response_format.modal.key_title"), - properties: { - key: { - type: "string", - }, - type: { - type: "string", - enum: ["string", "integer", "boolean", "array"], - }, - array_type: { - type: "string", - enum: ["string", "integer", "boolean"], - options: { - dependencies: { - type: "array", - }, - }, - }, - }, - required: ["key", "type"], - }, - }; - - get editorTitle() { - return i18n("discourse_ai.ai_persona.response_format.title"); - } - - get responseFormatAsJSON() { - return JSON.stringify(this.args.data.response_format); - } - - get displayJSON() { - const toDisplay = {}; - - this.args.data.response_format.forEach((keyDesc) => { - if (keyDesc.type === "array") { - toDisplay[keyDesc.key] = `[${keyDesc.array_type}]`; - } else { - toDisplay[keyDesc.key] = keyDesc.type; - } - }); - - return prettyJSON(toDisplay); - } - - @action - openModal() { - this.showJsonEditorModal = true; - } - - @action - closeModal() { - this.showJsonEditorModal = false; - } - - @action - updateResponseFormat(form, value) { - form.set("response_format", JSON.parse(value)); - } - - -} diff --git a/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs b/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs deleted file mode 100644 index 5c634997..00000000 --- a/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs +++ /dev/null @@ -1,234 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { array } from "@ember/helper"; -import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import didUpdate from "@ember/render-modifiers/modifiers/did-update"; -import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; -import { service } from "@ember/service"; -import { not } from "truth-helpers"; -import CookText from "discourse/components/cook-text"; -import DButton from "discourse/components/d-button"; -import DModal from "discourse/components/d-modal"; -import concatClass from "discourse/helpers/concat-class"; -import dIcon from "discourse/helpers/d-icon"; -import htmlClass from "discourse/helpers/html-class"; -import { ajax } from "discourse/lib/ajax"; -import { bind } from "discourse/lib/decorators"; -import { shortDateNoYear } from "discourse/lib/formatter"; -import { i18n } from "discourse-i18n"; -import DTooltip from "float-kit/components/d-tooltip"; -import AiSummarySkeleton from "../../components/ai-summary-skeleton"; -import SmoothStreamer from "../../lib/smooth-streamer"; - -export default class AiSummaryModal extends Component { - @service siteSettings; - @service messageBus; - @service currentUser; - @service site; - @service modal; - - @tracked text = ""; - @tracked summarizedOn = null; - @tracked summarizedBy = null; - @tracked newPostsSinceSummary = null; - @tracked outdated = false; - @tracked canRegenerate = false; - @tracked loading = false; - @tracked currentIndex = 0; - @tracked - smoothStreamer = new SmoothStreamer( - () => this.text, - (newValue) => (this.text = newValue) - ); - - get outdatedSummaryWarningText() { - let outdatedText = i18n("summary.outdated"); - - if (!this.topRepliesSummaryEnabled && this.newPostsSinceSummary > 0) { - outdatedText += " "; - outdatedText += i18n("summary.outdated_posts", { - count: this.newPostsSinceSummary, - }); - } - - return outdatedText; - } - - resetSummary() { - this.smoothStreamer.resetStreaming(); - this.currentIndex = 0; - this.text = ""; - this.summarizedOn = null; - this.summarizedBy = null; - this.newPostsSinceSummary = null; - this.outdated = false; - this.canRegenerate = false; - this.loading = false; - this._channel = null; - } - - get topRepliesSummaryEnabled() { - return this.args.model.postStream.summary; - } - - get topicId() { - return this.args.model.topic.id; - } - - get baseSummarizationURL() { - return `/discourse-ai/summarization/t/${this.topicId}`; - } - - @bind - subscribe() { - const channel = `/discourse-ai/summaries/topic/${this.args.model.topic.id}`; - this._channel = channel; - this.messageBus.subscribe(channel, this._updateSummary); - } - - @bind - unsubscribe() { - this.messageBus.unsubscribe( - "/discourse-ai/summaries/topic/*", - this._updateSummary - ); - this.resetSummary(); - } - - @action - generateSummary() { - let fetchURL = this.baseSummarizationURL; - - if (this.currentUser) { - fetchURL += `?stream=true`; - } - - return this._requestSummary(fetchURL); - } - - @action - regenerateSummary() { - let fetchURL = this.baseSummarizationURL; - - if (this.currentUser) { - fetchURL += `?stream=true`; - - if (this.canRegenerate) { - fetchURL += "&skip_age_check=true"; - } - } - - // ensure summary is reset before requesting a new one: - this.resetSummary(); - return this._requestSummary(fetchURL); - } - - @action - _requestSummary(url) { - if (this.loading || (this.text && !this.canRegenerate)) { - return; - } - - this.loading = true; - this.summarizedOn = null; - - return ajax(url).then((data) => { - if (data?.ai_topic_summary?.summarized_text) { - data.done = true; - this._updateSummary(data); - } - }); - } - - @bind - async _updateSummary(update) { - const topicSummary = { - done: update.done, - raw: update.ai_topic_summary?.summarized_text, - ...update.ai_topic_summary, - }; - - this.loading = false; - this.smoothStreamer.updateResult(topicSummary, "raw"); - - if (update.done) { - this.summarizedOn = shortDateNoYear( - moment(topicSummary.updated_at, "YYYY-MM-DD HH:mm:ss Z") - ); - this.summarizedBy = topicSummary.algorithm; - this.newPostsSinceSummary = topicSummary.new_posts_since_summary; - this.outdated = topicSummary.outdated; - this.canRegenerate = topicSummary.outdated && topicSummary.can_regenerate; - } - } - - @action - onRegisterApi(api) { - this.dMenu = api; - } - - @action - handleClose() { - this.modal.triggerElement = null; // prevent refocus of trigger, which changes scroll position - this.args.closeModal(); - } - - -} diff --git a/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs b/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs deleted file mode 100644 index 24bf40b2..00000000 --- a/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs +++ /dev/null @@ -1,83 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import DButton from "discourse/components/d-button"; -import DModal from "discourse/components/d-modal"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { i18n } from "discourse-i18n"; -import { jsonToHtml } from "../../lib/utilities"; - -export default class AiToolTestModal extends Component { - @tracked testResult; - @tracked isLoading = false; - parameterValues = {}; - - @action - updateParameter(name, event) { - this.parameterValues[name] = event.target.value; - } - - @action - async runTest() { - this.isLoading = true; - try { - const response = await ajax( - `/admin/plugins/discourse-ai/ai-tools/${this.args.model.tool.id}/test.json`, - { - type: "POST", - data: JSON.stringify({ - ai_tool: this.args.model.tool, - parameters: this.parameterValues, - }), - contentType: "application/json", - } - ); - this.testResult = jsonToHtml(response.output); - } catch (error) { - popupAjaxError(error); - } finally { - this.isLoading = false; - } - } - - -} diff --git a/assets/javascripts/discourse/components/modal/chat-modal-channel-summary.gjs b/assets/javascripts/discourse/components/modal/chat-modal-channel-summary.gjs deleted file mode 100644 index 25a75a2d..00000000 --- a/assets/javascripts/discourse/components/modal/chat-modal-channel-summary.gjs +++ /dev/null @@ -1,82 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import ConditionalLoadingSection from "discourse/components/conditional-loading-section"; -import DModal from "discourse/components/d-modal"; -import DModalCancel from "discourse/components/d-modal-cancel"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { i18n } from "discourse-i18n"; -import ComboBox from "select-kit/components/combo-box"; - -export default class ChatModalChannelSummary extends Component { - @service chatApi; - - @tracked sinceHours = null; - @tracked loading = false; - @tracked summary = null; - - availableSummaries = {}; - - sinceOptions = [1, 3, 6, 12, 24, 72, 168].map((hours) => { - return { - name: i18n("discourse_ai.summarization.chat.since", { count: hours }), - value: hours, - }; - }); - - get channelId() { - return this.args.model.channelId; - } - - @action - summarize(since) { - this.sinceHours = since; - this.loading = true; - - if (this.availableSummaries[since]) { - this.summary = this.availableSummaries[since]; - this.loading = false; - return; - } - - return ajax(`/discourse-ai/summarization/channels/${this.channelId}.json`, { - type: "GET", - data: { - since, - }, - }) - .then((data) => { - this.availableSummaries[this.sinceHours] = data.summary; - this.summary = this.availableSummaries[this.sinceHours]; - }) - .catch(popupAjaxError) - .finally(() => (this.loading = false)); - } - - -} diff --git a/assets/javascripts/discourse/components/modal/debug-ai-modal.gjs b/assets/javascripts/discourse/components/modal/debug-ai-modal.gjs deleted file mode 100644 index 135218d3..00000000 --- a/assets/javascripts/discourse/components/modal/debug-ai-modal.gjs +++ /dev/null @@ -1,194 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { next } from "@ember/runloop"; -import { htmlSafe } from "@ember/template"; -import DButton from "discourse/components/d-button"; -import DModal from "discourse/components/d-modal"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import discourseLater from "discourse/lib/later"; -import { clipboardCopy, escapeExpression } from "discourse/lib/utilities"; -import { i18n } from "discourse-i18n"; -import { jsonToHtml } from "../../lib/utilities"; - -export default class DebugAiModal extends Component { - @tracked info = null; - @tracked justCopiedText = ""; - @tracked activeTab = "request"; - - constructor() { - super(...arguments); - next(() => { - this.loadApiRequestInfo(); - }); - } - - get htmlContext() { - if (!this.info) { - return ""; - } - - let parsed; - - try { - if (this.activeTab === "request") { - parsed = JSON.parse(this.info.raw_request_payload); - } else { - return this.formattedResponse(this.info.raw_response_payload); - } - } catch { - return this.info.raw_request_payload; - } - - return jsonToHtml(parsed); - } - - formattedResponse(response) { - // we need to replace the new lines with
to make it look good - const split = response.split("\n"); - const safe = split.map((line) => escapeExpression(line)).join("
"); - - return htmlSafe(safe); - } - - @action - copyRequest() { - this.copy(this.info.raw_request_payload); - } - - @action - copyResponse() { - this.copy(this.info.raw_response_payload); - } - - async loadLog(logId) { - try { - await ajax(`/discourse-ai/ai-bot/show-debug-info/${logId}.json`).then( - (result) => { - this.info = result; - } - ); - } catch (e) { - popupAjaxError(e); - } - } - - @action - prevLog() { - this.loadLog(this.info.prev_log_id); - } - - @action - nextLog() { - this.loadLog(this.info.next_log_id); - } - - copy(text) { - clipboardCopy(text); - this.justCopiedText = i18n("discourse_ai.ai_bot.conversation_shared"); - - discourseLater(() => { - this.justCopiedText = ""; - }, 2000); - } - - loadApiRequestInfo() { - ajax(`/discourse-ai/ai-bot/post/${this.args.model.id}/show-debug-info.json`) - .then((result) => { - this.info = result; - }) - .catch((e) => { - popupAjaxError(e); - }); - } - - get requestActive() { - return this.activeTab === "request" ? "active" : ""; - } - - get responseActive() { - return this.activeTab === "response" ? "active" : ""; - } - - @action - requestClicked(e) { - this.activeTab = "request"; - e.preventDefault(); - } - - @action - responseClicked(e) { - this.activeTab = "response"; - e.preventDefault(); - } - - -} diff --git a/assets/javascripts/discourse/components/modal/diff-modal.gjs b/assets/javascripts/discourse/components/modal/diff-modal.gjs deleted file mode 100644 index eaceee5e..00000000 --- a/assets/javascripts/discourse/components/modal/diff-modal.gjs +++ /dev/null @@ -1,250 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; -import { service } from "@ember/service"; -import { htmlSafe } from "@ember/template"; -import { or } from "truth-helpers"; -import CookText from "discourse/components/cook-text"; -import DButton from "discourse/components/d-button"; -import DModal from "discourse/components/d-modal"; -import concatClass from "discourse/helpers/concat-class"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { bind } from "discourse/lib/decorators"; -import { escapeExpression } from "discourse/lib/utilities"; -import { i18n } from "discourse-i18n"; -import DiffStreamer from "../../lib/diff-streamer"; -import SmoothStreamer from "../../lib/smooth-streamer"; -import AiIndicatorWave from "../ai-indicator-wave"; - -export default class ModalDiffModal extends Component { - @service currentUser; - @service messageBus; - - @tracked loading = false; - @tracked finalResult = ""; - @tracked escapedSelectedText = escapeExpression(this.args.model.selectedText); - @tracked diffStreamer = new DiffStreamer(this.args.model.selectedText); - @tracked suggestion = ""; - @tracked - smoothStreamer = new SmoothStreamer( - () => this.suggestion, - (newValue) => (this.suggestion = newValue) - ); - - constructor() { - super(...arguments); - this.suggestChanges(); - } - - get diffResult() { - if (this.loading) { - return this.escapedSelectedText; - } - - if (this.diffStreamer.diff?.length > 0) { - return this.diffStreamer.diff; - } - - // Prevents flash by showing the - // original text when the diff is empty - return this.escapedSelectedText; - } - - get smoothStreamerResult() { - if (this.loading) { - return this.escapedSelectedText; - } - - return this.smoothStreamer.renderedText; - } - - get isStreaming() { - // diffStreamer stops Streaming when it is finished with a chunk, looking at isDone is safe - // it starts off not done - if (this.args.model.showResultAsDiff) { - return !this.diffStreamer.isDone; - } - - return this.smoothStreamer.isStreaming; - } - - get primaryBtnLabel() { - return this.loading - ? i18n("discourse_ai.ai_helper.context_menu.loading") - : i18n("discourse_ai.ai_helper.context_menu.confirm"); - } - - get primaryBtnDisabled() { - return this.loading || this.isStreaming; - } - - set progressChannel(value) { - if (this._progressChannel) { - this.messageBus.unsubscribe(this._progressChannel, this.updateResult); - } - this._progressChannel = value; - this.subscribe(); - } - - subscribe() { - // we have 1 channel per operation so we can safely subscribe at head - this.messageBus.subscribe(this._progressChannel, this.updateResult, 0); - } - - @bind - cleanup() { - // stop all callbacks so it does not end up streaming pointlessly - this.#resetState(); - if (this._progressChannel) { - this.messageBus.unsubscribe(this._progressChannel, this.updateResult); - } - } - - @action - updateResult(result) { - this.loading = false; - - if (result.done) { - this.finalResult = result.result; - this.loading = false; - } - - if (this.args.model.showResultAsDiff) { - this.diffStreamer.updateResult(result, "result"); - } else { - this.smoothStreamer.updateResult(result, "result"); - } - } - - @action - async suggestChanges() { - this.#resetState(); - - try { - this.loading = true; - const result = await ajax("/discourse-ai/ai-helper/stream_suggestion", { - method: "POST", - data: { - location: "composer", - mode: this.args.model.mode, - text: this.args.model.selectedText, - custom_prompt: this.args.model.customPromptValue, - force_default_locale: true, - client_id: this.messageBus.clientId, - }, - }); - - this.progressChannel = result.progress_channel; - } catch (e) { - popupAjaxError(e); - } - } - - @action - triggerConfirmChanges() { - this.args.closeModal(); - - if (this.suggestion) { - this.args.model.toolbarEvent.replaceText( - this.args.model.selectedText, - this.suggestion - ); - } - - const finalResult = - this.finalResult?.length > 0 - ? this.finalResult - : this.diffStreamer.suggestion; - - if (this.args.model.showResultAsDiff && finalResult) { - this.args.model.toolbarEvent.replaceText( - this.args.model.selectedText, - finalResult - ); - } - } - - @action - cleanupAndClose() { - this.#resetState(); - this.loading = false; - this.args.closeModal(); - } - - #resetState() { - this.suggestion = ""; - this.finalResult = ""; - this.smoothStreamer.resetStreaming(); - this.diffStreamer.reset(); - } - - -} diff --git a/assets/javascripts/discourse/components/modal/share-full-topic-modal.gjs b/assets/javascripts/discourse/components/modal/share-full-topic-modal.gjs deleted file mode 100644 index d2987578..00000000 --- a/assets/javascripts/discourse/components/modal/share-full-topic-modal.gjs +++ /dev/null @@ -1,154 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import { htmlSafe } from "@ember/template"; -import DButton from "discourse/components/d-button"; -import DModal from "discourse/components/d-modal"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { getAbsoluteURL } from "discourse/lib/get-url"; -import { clipboardCopyAsync } from "discourse/lib/utilities"; -import { i18n } from "discourse-i18n"; - -export default class ShareModal extends Component { - @service toasts; - - @tracked shareKey = ""; - - constructor() { - super(...arguments); - this.shareKey = this.args.model.share_key; - } - - get htmlContext() { - let context = []; - - this.args.model.context.forEach((post) => { - context.push(`

${post.username}:

`); - context.push(post.cooked); - }); - return htmlSafe(context.join("\n")); - } - - async generateShareURL() { - try { - const response = await ajax( - "/discourse-ai/ai-bot/shared-ai-conversations", - { - type: "POST", - data: { - topic_id: this.args.model.topic_id, - }, - } - ); - - const url = getAbsoluteURL( - `/discourse-ai/ai-bot/shared-ai-conversations/${response.share_key}` - ); - this.shareKey = response.share_key; - - return new Blob([url], { type: "text/plain" }); - } catch (e) { - popupAjaxError(e); - return; - } - } - - get primaryLabel() { - return this.shareKey - ? "discourse_ai.ai_bot.share_full_topic_modal.update" - : "discourse_ai.ai_bot.share_full_topic_modal.share"; - } - - @action - async deleteLink() { - try { - await ajax( - `/discourse-ai/ai-bot/shared-ai-conversations/${this.shareKey}.json`, - { - type: "DELETE", - } - ); - - this.shareKey = null; - } catch (e) { - popupAjaxError(e); - } - } - - @action - async share() { - await clipboardCopyAsync(this.generateShareURL.bind(this)); - this.toasts.success({ - duration: "short", - data: { - message: i18n("discourse_ai.ai_bot.conversation_shared"), - }, - }); - } - - @action - async maybeCopyEmbed(event) { - if (!event.target.classList.contains("copy-embed")) { - return true; - } - event.stopPropagation(); - event.preventDefault(); - - let version = ""; - if (event.target.dataset.artifactVersion) { - version = `data-ai-artifact-version="${event.target.dataset.artifactVersion}"`; - } - const artifactEmbed = `
`; - const promise = new Promise((resolve) => { - resolve(artifactEmbed); - }); - - await clipboardCopyAsync(() => promise); - - this.toasts.success({ - duration: "short", - data: { - message: i18n("discourse_ai.ai_bot.embed_copied"), - }, - }); - } - - -} diff --git a/assets/javascripts/discourse/components/modal/share-modal.gjs b/assets/javascripts/discourse/components/modal/share-modal.gjs deleted file mode 100644 index 8fda19c6..00000000 --- a/assets/javascripts/discourse/components/modal/share-modal.gjs +++ /dev/null @@ -1,130 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { Input } from "@ember/component"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import { htmlSafe } from "@ember/template"; -import DButton from "discourse/components/d-button"; -import DModal from "discourse/components/d-modal"; -import discourseLater from "discourse/lib/later"; -import { i18n } from "discourse-i18n"; -import { showShareConversationModal } from "../../lib/ai-bot-helper"; -import copyConversation from "../../lib/copy-conversation"; - -export default class ShareModal extends Component { - @service modal; - @service siteSettings; - @service currentUser; - - @tracked contextValue = 1; - @tracked htmlContext = ""; - @tracked maxContext = 0; - @tracked allPosts = []; - @tracked justCopiedText = ""; - - constructor() { - super(...arguments); - - const postStream = this.args.model.topic.get("postStream"); - - let postNumbers = []; - // simpler to understand than Array.from - for (let i = 1; i <= this.args.model.post_number; i++) { - postNumbers.push(i); - } - - this.allPosts = postNumbers - .map((postNumber) => { - let postId = postStream.findPostIdForPostNumber(postNumber); - if (postId) { - return postStream.findLoadedPost(postId); - } - }) - .filter((post) => post); - - this.maxContext = this.allPosts.length / 2; - this.contextValue = 1; - - this.updateHtmlContext(); - } - - @action - updateHtmlContext() { - let context = []; - - const start = this.allPosts.length - this.contextValue * 2; - for (let i = start; i < this.allPosts.length; i++) { - const post = this.allPosts[i]; - context.push(`

${post.username}:

`); - context.push(post.cooked); - } - this.htmlContext = htmlSafe(context.join("\n")); - } - - @action - async copyContext() { - const from = - this.allPosts[this.allPosts.length - this.contextValue * 2].post_number; - const to = this.args.model.post_number; - - await copyConversation(this.args.model.topic, from, to); - this.justCopiedText = i18n("discourse_ai.ai_bot.conversation_shared"); - - discourseLater(() => { - this.justCopiedText = ""; - }, 2000); - } - - @action - shareConversationModal(event) { - event?.preventDefault(); - this.args.closeModal(); - showShareConversationModal(this.modal, this.args.model.topic_id); - return false; - } - - -} diff --git a/assets/javascripts/discourse/components/modal/spam-test-modal.gjs b/assets/javascripts/discourse/components/modal/spam-test-modal.gjs deleted file mode 100644 index 88143cef..00000000 --- a/assets/javascripts/discourse/components/modal/spam-test-modal.gjs +++ /dev/null @@ -1,99 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import DButton from "discourse/components/d-button"; -import DModal from "discourse/components/d-modal"; -import withEventValue from "discourse/helpers/with-event-value"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { i18n } from "discourse-i18n"; -import AiIndicatorWave from "../ai-indicator-wave"; - -export default class SpamTestModal extends Component { - @tracked testResult; - @tracked isLoading = false; - @tracked postUrl = ""; - @tracked scanLog = ""; - @tracked isSpam; - - @action - async runTest() { - this.isLoading = true; - try { - const response = await ajax( - `/admin/plugins/discourse-ai/ai-spam/test.json`, - { - type: "POST", - data: { - post_url: this.postUrl, - custom_instructions: this.args.model.customInstructions, - llm_id: this.args.model.llmId, - }, - } - ); - - this.isSpam = response.is_spam; - this.testResult = response.is_spam - ? i18n("discourse_ai.spam.test_modal.spam") - : i18n("discourse_ai.spam.test_modal.not_spam"); - this.scanLog = response.log; - } catch (error) { - popupAjaxError(error); - } finally { - this.isLoading = false; - } - } - - -} diff --git a/assets/javascripts/discourse/components/modal/thumbnail-suggestions.gjs b/assets/javascripts/discourse/components/modal/thumbnail-suggestions.gjs deleted file mode 100644 index 718d3257..00000000 --- a/assets/javascripts/discourse/components/modal/thumbnail-suggestions.gjs +++ /dev/null @@ -1,110 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; -import DButton from "discourse/components/d-button"; -import DModal from "discourse/components/d-modal"; -import DModalCancel from "discourse/components/d-modal-cancel"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { i18n } from "discourse-i18n"; -import ThumbnailSuggestionItem from "../thumbnail-suggestion-item"; - -export default class ThumbnailSuggestions extends Component { - @tracked loading = false; - @tracked selectedImages = []; - @tracked thumbnails = null; - - constructor() { - super(...arguments); - - this.findThumbnails(); - } - - get isDisabled() { - return this.selectedImages.length === 0; - } - - async findThumbnails() { - this.loading = true; - try { - const thumbnails = await ajax("/discourse-ai/ai-helper/suggest", { - method: "POST", - data: { - mode: this.args.model.mode, - text: this.args.model.selectedText, - force_default_locale: true, - }, - }); - - this.thumbnails = thumbnails.thumbnails; - } catch (error) { - popupAjaxError(error); - } finally { - this.loading = false; - } - } - - @action - addSelection(selection) { - const thumbnailMarkdown = `![${selection.original_filename}|${selection.width}x${selection.height}](${selection.short_url})`; - this.selectedImages = [...this.selectedImages, thumbnailMarkdown]; - } - - @action - removeSelection(selection) { - const thumbnailMarkdown = `![${selection.original_filename}|${selection.width}x${selection.height}](${selection.short_url})`; - - this.selectedImages = this.selectedImages.filter((thumbnail) => { - if (thumbnail !== thumbnailMarkdown) { - return thumbnail; - } - }); - } - - @action - appendSelectedImages() { - const imageMarkdown = "\n\n" + this.selectedImages.join("\n"); - - const dEditorInput = document.querySelector(".d-editor-input"); - dEditorInput.setSelectionRange( - dEditorInput.value.length, - dEditorInput.value.length - ); - dEditorInput.focus(); - document.execCommand("insertText", false, imageMarkdown); - this.args.closeModal(); - } - - -} diff --git a/assets/javascripts/discourse/components/model-accuracies.hbs b/assets/javascripts/discourse/components/model-accuracies.hbs deleted file mode 100644 index e2dc68a0..00000000 --- a/assets/javascripts/discourse/components/model-accuracies.hbs +++ /dev/null @@ -1,14 +0,0 @@ -{{#if @accuracies}} - - - {{#each-in @accuracies as |model acc|}} - - - - - - - {{/each-in}} - -
{{i18n "discourse_ai.reviewables.model_used"}}{{model}}{{i18n "discourse_ai.reviewables.accuracy"}}{{acc}}%
-{{/if}} \ No newline at end of file diff --git a/assets/javascripts/discourse/components/post-menu/ai-cancel-streaming-button.gjs b/assets/javascripts/discourse/components/post-menu/ai-cancel-streaming-button.gjs deleted file mode 100644 index 3fa2170e..00000000 --- a/assets/javascripts/discourse/components/post-menu/ai-cancel-streaming-button.gjs +++ /dev/null @@ -1,34 +0,0 @@ -import Component from "@glimmer/component"; -import { action } from "@ember/object"; -import DButton from "discourse/components/d-button"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; - -export default class AiCancelStreamingButton extends Component { - @action - async cancelStreaming() { - const post = this.args.post; - - try { - await ajax(`/discourse-ai/ai-bot/post/${post.id}/stop-streaming`, { - type: "POST", - }); - - document - .querySelector(`#post_${post.post_number}`) - .classList.remove("streaming"); - } catch (e) { - popupAjaxError(e); - } - } - - -} diff --git a/assets/javascripts/discourse/components/post-menu/ai-debug-button.gjs b/assets/javascripts/discourse/components/post-menu/ai-debug-button.gjs deleted file mode 100644 index 5ee57f4e..00000000 --- a/assets/javascripts/discourse/components/post-menu/ai-debug-button.gjs +++ /dev/null @@ -1,29 +0,0 @@ -import Component from "@glimmer/component"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import { isPostFromAiBot } from "../../lib/ai-bot-helper"; -import DebugAiModal from "../modal/debug-ai-modal"; - -export default class AiDebugButton extends Component { - static shouldRender(args) { - return isPostFromAiBot(args.post, args.state.currentUser); - } - - @service modal; - - @action - debugAiResponse() { - this.modal.show(DebugAiModal, { model: this.args.post }); - } - - -} diff --git a/assets/javascripts/discourse/components/post-menu/ai-share-button.gjs b/assets/javascripts/discourse/components/post-menu/ai-share-button.gjs deleted file mode 100644 index 28704e32..00000000 --- a/assets/javascripts/discourse/components/post-menu/ai-share-button.gjs +++ /dev/null @@ -1,39 +0,0 @@ -import Component from "@glimmer/component"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import { isPostFromAiBot } from "../../lib/ai-bot-helper"; -import copyConversation from "../../lib/copy-conversation"; -import ShareModal from "../modal/share-modal"; - -const AUTO_COPY_THRESHOLD = 4; - -export default class AiDebugButton extends Component { - static shouldRender(args) { - return isPostFromAiBot(args.post, args.state.currentUser); - } - - @service modal; - - @action - async shareAiResponse() { - const post = this.args.post; - - if (post.post_number <= AUTO_COPY_THRESHOLD) { - await copyConversation(post.topic, 1, post.post_number); - this.args.showFeedback("discourse_ai.ai_bot.conversation_shared"); - } else { - this.modal.show(ShareModal, { model: post }); - } - } - - -} diff --git a/assets/javascripts/discourse/components/post/ai-persona-flair.gjs b/assets/javascripts/discourse/components/post/ai-persona-flair.gjs deleted file mode 100644 index af637159..00000000 --- a/assets/javascripts/discourse/components/post/ai-persona-flair.gjs +++ /dev/null @@ -1,14 +0,0 @@ -import Component from "@glimmer/component"; -import { isGPTBot } from "../../lib/ai-bot-helper"; - -export default class AiPersonaFlair extends Component { - static shouldRender(args) { - return isGPTBot(args.post.user); - } - - -} diff --git a/assets/javascripts/discourse/components/rag-options-fk.gjs b/assets/javascripts/discourse/components/rag-options-fk.gjs deleted file mode 100644 index e540f744..00000000 --- a/assets/javascripts/discourse/components/rag-options-fk.gjs +++ /dev/null @@ -1,81 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { i18n } from "discourse-i18n"; -import AiLlmSelector from "./ai-llm-selector"; - -export default class RagOptionsFk extends Component { - @tracked showIndexingOptions = false; - - @action - toggleIndexingOptions(event) { - this.showIndexingOptions = !this.showIndexingOptions; - event.preventDefault(); - event.stopPropagation(); - } - - get indexingOptionsText() { - return this.showIndexingOptions - ? i18n("discourse_ai.rag.options.hide_indexing_options") - : i18n("discourse_ai.rag.options.show_indexing_options"); - } - - get visionLlms() { - return this.args.llms.filter((llm) => llm.vision_enabled); - } - - -} diff --git a/assets/javascripts/discourse/components/rag-upload-progress.gjs b/assets/javascripts/discourse/components/rag-upload-progress.gjs deleted file mode 100644 index d6481018..00000000 --- a/assets/javascripts/discourse/components/rag-upload-progress.gjs +++ /dev/null @@ -1,87 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import { service } from "@ember/service"; -import icon from "discourse/helpers/d-icon"; -import { bind } from "discourse/lib/decorators"; -import { i18n } from "discourse-i18n"; - -export default class RagUploadProgress extends Component { - @service messageBus; - - @tracked updatedProgress = null; - - willDestroy() { - super.willDestroy(...arguments); - this.messageBus.unsubscribe(`/discourse-ai/rag/${this.args.upload.id}`); - } - - @action - trackProgress() { - this.messageBus.subscribe( - `/discourse-ai/rag/${this.args.upload.id}`, - this.onIndexingUpdate - ); - } - - @bind - onIndexingUpdate(data) { - // Order not guaranteed. Discard old updates. - if ( - !this.updatedProgress || - this.updatedProgress.left === 0 || - this.updatedProgress.left > data.left || - data.total === data.indexed - ) { - this.updatedProgress = data; - } - } - - get calculateProgress() { - if (this.progress.total === 0) { - return 0; - } - - return Math.ceil((this.progress.indexed * 100) / this.progress.total); - } - - get fullyIndexed() { - return ( - this.progress && this.progress.total !== 0 && this.progress.left === 0 - ); - } - - get progress() { - if (this.updatedProgress) { - return this.updatedProgress; - } else if (this.args.ragIndexingStatuses) { - return this.args.ragIndexingStatuses[this.args.upload.id]; - } else { - return []; - } - } - - -} diff --git a/assets/javascripts/discourse/components/rag-uploader.gjs b/assets/javascripts/discourse/components/rag-uploader.gjs deleted file mode 100644 index 36ffa88d..00000000 --- a/assets/javascripts/discourse/components/rag-uploader.gjs +++ /dev/null @@ -1,215 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import Component, { Input } from "@ember/component"; -import { fn } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { getOwner } from "@ember/owner"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import icon from "discourse/helpers/d-icon"; -import { ajax } from "discourse/lib/ajax"; -import discourseDebounce from "discourse/lib/debounce"; -import UppyUpload from "discourse/lib/uppy/uppy-upload"; -import { i18n } from "discourse-i18n"; -import RagUploadProgress from "./rag-upload-progress"; - -export default class RagUploader extends Component { - @service appEvents; - - @tracked term = null; - @tracked filteredUploads = null; - @tracked ragIndexingStatuses = null; - @tracked ragUploads = null; - - uppyUpload = new UppyUpload(getOwner(this), { - id: "discourse-ai-rag-uploader", - maxFiles: 20, - type: "discourse_ai_rag_upload", - uploadUrl: - "/admin/plugins/discourse-ai/rag-document-fragments/files/upload", - preventDirectS3Uploads: true, - uploadDone: (uploadedFile) => { - const newUpload = uploadedFile.upload; - newUpload.status = "uploaded"; - newUpload.statusText = i18n("discourse_ai.rag.uploads.uploaded"); - this.ragUploads.pushObject(newUpload); - this.debouncedSearch(); - }, - }); - - willDestroy() { - super.willDestroy(...arguments); - this.appEvents.off( - `upload-mixin:${this.uppyUpload.config}:all-uploads-complete`, - this, - "_updateTargetWithUploads" - ); - } - - didReceiveAttrs() { - super.didReceiveAttrs(...arguments); - - if (this.uppyUpload.inProgressUploads?.length > 0) { - this.uppyUpload.cancelAllUploads(); - } - - this.ragUploads = this.target?.rag_uploads?.slice() || []; - this.filteredUploads = this.ragUploads; - - const targetName = this.targetName || this.target?.constructor?.name; - if (this.ragUploads?.length && this.target?.id) { - ajax( - `/admin/plugins/discourse-ai/rag-document-fragments/files/status.json?target_type=${targetName}&target_id=${this.target.id}` - ).then((statuses) => { - this.set("ragIndexingStatuses", statuses); - }); - } - - this.appEvents.on( - `upload-mixin:${this.uppyUpload.config.id}:all-uploads-complete`, - this, - "_updateTargetWithUploads" - ); - } - - _updateTargetWithUploads() { - this.updateUploads(this.ragUploads); - } - - get acceptedFileTypes() { - if (this.args?.allowImages) { - return ".txt,.md,.png,.jpg,.jpeg"; - } else { - return ".txt,.md,.pdf"; - } - } - - @action - submitFiles() { - this.uppyUpload.openPicker(); - } - - @action - cancelUploading(upload) { - this.uppyUpload.cancelSingleUpload({ - fileId: upload.id, - }); - } - - @action - search() { - if (this.term) { - this.filteredUploads = this.ragUploads.filter((u) => { - return ( - u.original_filename.toUpperCase().indexOf(this.term.toUpperCase()) > - -1 - ); - }); - } else { - this.filteredUploads = this.ragUploads; - } - } - - @action - debouncedSearch() { - discourseDebounce(this, this.search, 100); - } - - @action - removeUpload(upload) { - this.ragUploads.removeObject(upload); - this.onRemove(upload); - - this.debouncedSearch(); - } - - -} diff --git a/assets/javascripts/discourse/components/reviewable-ai-chat-message.hbs b/assets/javascripts/discourse/components/reviewable-ai-chat-message.hbs deleted file mode 100644 index c3108924..00000000 --- a/assets/javascripts/discourse/components/reviewable-ai-chat-message.hbs +++ /dev/null @@ -1,33 +0,0 @@ -{{#if this.chatChannel}} -
- - - -
-{{/if}} - -
- -
- - -
- {{html-safe (or @reviewable.payload.message_cooked @reviewable.cooked)}} -
- - {{yield}} - - -
-
\ No newline at end of file diff --git a/assets/javascripts/discourse/components/reviewable-ai-chat-message.js b/assets/javascripts/discourse/components/reviewable-ai-chat-message.js deleted file mode 100644 index b87ec5d4..00000000 --- a/assets/javascripts/discourse/components/reviewable-ai-chat-message.js +++ /dev/null @@ -1,11 +0,0 @@ -import Component from "@glimmer/component"; -import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; - -export default class ReviewableAiChatMessage extends Component { - get chatChannel() { - if (!this.args.reviewable.chat_channel) { - return; - } - return ChatChannel.create(this.args.reviewable.chat_channel); - } -} diff --git a/assets/javascripts/discourse/components/reviewable-ai-post.hbs b/assets/javascripts/discourse/components/reviewable-ai-post.hbs deleted file mode 100644 index b6bf2248..00000000 --- a/assets/javascripts/discourse/components/reviewable-ai-post.hbs +++ /dev/null @@ -1,26 +0,0 @@ -
- - -
- -
- -
- -
- {{#if @reviewable.blank_post}} -

{{i18n "review.deleted_post"}}

- {{else}} - {{html-safe @reviewable.cooked}} - {{/if}} -
- - {{yield}} - - -
-
\ No newline at end of file diff --git a/assets/javascripts/discourse/components/suggestion-menus/ai-category-suggester.gjs b/assets/javascripts/discourse/components/suggestion-menus/ai-category-suggester.gjs deleted file mode 100644 index 9847255a..00000000 --- a/assets/javascripts/discourse/components/suggestion-menus/ai-category-suggester.gjs +++ /dev/null @@ -1,175 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import DropdownMenu from "discourse/components/dropdown-menu"; -import categoryBadge from "discourse/helpers/category-badge"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { i18n } from "discourse-i18n"; -import DMenu from "float-kit/components/d-menu"; -import { - MIN_CHARACTER_COUNT, - showSuggestionsError, -} from "../../lib/ai-helper-suggestions"; - -export default class AiCategorySuggester extends Component { - @service siteSettings; - - @tracked loading = false; - @tracked suggestions = null; - @tracked untriggers = []; - @tracked triggerIcon = "discourse-sparkles"; - @tracked content = null; - - get showSuggestionButton() { - const composerFields = document.querySelector(".composer-fields"); - this.content = this.args.composer?.reply; - const showTrigger = - this.content?.length > MIN_CHARACTER_COUNT || - this.args.topicState === "edit"; - - if (composerFields) { - if (showTrigger) { - composerFields.classList.add("showing-ai-suggestions"); - } else { - composerFields.classList.remove("showing-ai-suggestions"); - } - } - - return this.siteSettings.ai_embeddings_enabled && showTrigger; - } - - get showDropdown() { - if (this.suggestions?.length <= 0) { - this.dMenu.close(); - } - return !this.loading && this.suggestions?.length > 0; - } - - @action - async loadSuggestions() { - if ( - this.suggestions && - this.suggestions?.length > 0 && - !this.dMenu.expanded - ) { - return this.suggestions; - } - - this.loading = true; - this.triggerIcon = "spinner"; - - const data = {}; - - if (this.content) { - data.text = this.content; - } else { - data.topic_id = this.args.buffered.content.id; - } - - try { - const { assistant } = await ajax( - "/discourse-ai/ai-helper/suggest_category", - { - method: "POST", - data, - } - ); - - this.suggestions = assistant; - - if (this.suggestions?.length <= 0) { - showSuggestionsError(this, this.loadSuggestions.bind(this)); - return; - } - } catch (error) { - popupAjaxError(error); - } finally { - this.loading = false; - this.triggerIcon = "rotate"; - } - - return this.suggestions; - } - - @action - applySuggestion(suggestion) { - const composer = this.args.composer; - const buffered = this.args.buffered; - - if (composer) { - composer.set("categoryId", suggestion.id); - composer.get("categoryId"); - } - - if (buffered) { - this.args.buffered.set("category_id", suggestion.id); - } - - return this.dMenu.close(); - } - - @action - onRegisterApi(api) { - this.dMenu = api; - } - - @action - onClose() { - if (this.suggestions?.length > 0) { - // If all suggestions have been used, - // re-triggering when no suggestions present - // will cause computation issues with - // setting the icon, so we prevent it - this.triggerIcon = "discourse-sparkles"; - } - } - - -} diff --git a/assets/javascripts/discourse/components/suggestion-menus/ai-tag-suggester.gjs b/assets/javascripts/discourse/components/suggestion-menus/ai-tag-suggester.gjs deleted file mode 100644 index 32279820..00000000 --- a/assets/javascripts/discourse/components/suggestion-menus/ai-tag-suggester.gjs +++ /dev/null @@ -1,219 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import DropdownMenu from "discourse/components/dropdown-menu"; -import discourseTag from "discourse/helpers/discourse-tag"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { i18n } from "discourse-i18n"; -import DMenu from "float-kit/components/d-menu"; -import { - MIN_CHARACTER_COUNT, - showSuggestionsError, -} from "../../lib/ai-helper-suggestions"; - -export default class AiTagSuggester extends Component { - @service siteSettings; - @service toasts; - @service composer; - - @tracked loading = false; - @tracked suggestions = null; - @tracked untriggers = []; - @tracked triggerIcon = "discourse-sparkles"; - @tracked content = null; - - get showSuggestionButton() { - if (this.composer.disableTagsChooser) { - return false; - } - - const composerFields = document.querySelector(".composer-fields"); - this.content = this.args.composer?.reply; - const showTrigger = - this.content?.length > MIN_CHARACTER_COUNT || - this.args.topicState === "edit"; - - if (composerFields) { - if (showTrigger) { - composerFields.classList.add("showing-ai-suggestions"); - } else { - composerFields.classList.remove("showing-ai-suggestions"); - } - } - - return this.siteSettings.ai_embeddings_enabled && showTrigger; - } - - get showDropdown() { - if (this.suggestions?.length <= 0) { - this.dMenu.close(); - } - return !this.loading && this.suggestions?.length > 0; - } - - @action - async loadSuggestions() { - if ( - this.suggestions && - this.suggestions?.length > 0 && - !this.dMenu.expanded - ) { - return this.suggestions; - } - - this.loading = true; - this.triggerIcon = "spinner"; - - const data = {}; - - if (this.content) { - data.text = this.content; - } else { - data.topic_id = this.args.buffered.content.id; - } - - try { - const { assistant } = await ajax("/discourse-ai/ai-helper/suggest_tags", { - method: "POST", - data, - }); - - this.suggestions = assistant; - - const model = this.args.composer - ? this.args.composer - : this.args.buffered; - - if (this.#tagSelectorHasValues()) { - this.suggestions = this.suggestions.filter( - (s) => !model.get("tags").includes(s.name) - ); - } - - if (this.suggestions?.length <= 0) { - showSuggestionsError(this, this.loadSuggestions.bind(this)); - return; - } - } catch (error) { - popupAjaxError(error); - } finally { - this.loading = false; - this.triggerIcon = "rotate"; - } - - return this.suggestions; - } - - #tagSelectorHasValues() { - const model = this.args.composer ? this.args.composer : this.args.buffered; - - return model.get("tags") && model.get("tags").length > 0; - } - - #removedAppliedTag(suggestion) { - return (this.suggestions = this.suggestions.filter( - (s) => s.id !== suggestion.id - )); - } - - @action - applySuggestion(suggestion) { - const maxTags = this.siteSettings.max_tags_per_topic; - const model = this.args.composer ? this.args.composer : this.args.buffered; - if (!model) { - return; - } - - const tags = model.get("tags"); - - if (!tags) { - model.set("tags", [suggestion.name]); - this.#removedAppliedTag(suggestion); - return; - } - - if (tags?.length >= maxTags) { - return this.toasts.error({ - class: "ai-suggestion-error", - duration: "short", - data: { - message: i18n("discourse_ai.ai_helper.suggest_errors.too_many_tags", { - count: maxTags, - }), - }, - }); - } - - tags.push(suggestion.name); - model.set("tags", [...tags]); - suggestion.disabled = true; - this.#removedAppliedTag(suggestion); - } - - @action - onRegisterApi(api) { - this.dMenu = api; - } - - @action - onClose() { - if (this.suggestions?.length > 0) { - // If all suggestions have been used, - // re-triggering when no suggestions present - // will cause computation issues with - // setting the icon, so we prevent it - this.triggerIcon = "discourse-sparkles"; - } - } - - -} diff --git a/assets/javascripts/discourse/components/suggestion-menus/ai-title-suggester.gjs b/assets/javascripts/discourse/components/suggestion-menus/ai-title-suggester.gjs deleted file mode 100644 index 60a86897..00000000 --- a/assets/javascripts/discourse/components/suggestion-menus/ai-title-suggester.gjs +++ /dev/null @@ -1,169 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import DButton from "discourse/components/d-button"; -import DropdownMenu from "discourse/components/dropdown-menu"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { i18n } from "discourse-i18n"; -import DMenu from "float-kit/components/d-menu"; -import { - MIN_CHARACTER_COUNT, - showSuggestionsError, -} from "../../lib/ai-helper-suggestions"; - -export default class AiTitleSuggester extends Component { - @tracked loading = false; - @tracked suggestions = null; - @tracked untriggers = []; - @tracked triggerIcon = "discourse-sparkles"; - @tracked content = null; - - get showSuggestionButton() { - const composerFields = document.querySelector(".composer-fields"); - const editTopicTitleField = document.querySelector(".edit-topic-title"); - - this.content = this.args.composer?.reply; - const showTrigger = - this.content?.length > MIN_CHARACTER_COUNT || - this.args.topicState === "edit"; - - if (composerFields) { - if (showTrigger) { - composerFields.classList.add("showing-ai-suggestions"); - } else { - composerFields.classList.remove("showing-ai-suggestions"); - } - } - - if (editTopicTitleField) { - if (showTrigger) { - editTopicTitleField.classList.add("showing-ai-suggestions"); - } else { - editTopicTitleField.classList.remove("showing-ai-suggestions"); - } - } - - return showTrigger; - } - - get showDropdown() { - if (this.suggestions?.length <= 0) { - this.dMenu.close(); - } - return !this.loading && this.suggestions?.length > 0; - } - - @action - async loadSuggestions() { - if ( - this.suggestions && - this.suggestions?.length > 0 && - !this.dMenu.expanded - ) { - return this.suggestions; - } - - this.loading = true; - this.triggerIcon = "spinner"; - const data = {}; - - if (this.content) { - data.text = this.content; - } else { - data.topic_id = this.args.buffered.content.id; - } - - try { - const { suggestions } = await ajax( - "/discourse-ai/ai-helper/suggest_title", - { - method: "POST", - data, - } - ); - - this.suggestions = suggestions; - - if (this.suggestions?.length <= 0) { - showSuggestionsError(this, this.loadSuggestions.bind(this)); - return; - } - } catch (error) { - popupAjaxError(error); - } finally { - this.loading = false; - this.triggerIcon = "rotate"; - } - - return this.suggestions; - } - - @action - applySuggestion(suggestion) { - const model = this.args.composer ? this.args.composer : this.args.buffered; - if (!model) { - return; - } - - model.set("title", suggestion); - this.dMenu.close(); - } - - @action - onRegisterApi(api) { - this.dMenu = api; - } - - @action - onClose() { - if (this.suggestions?.length > 0) { - // If all suggestions have been used, - // re-triggering when no suggestions present - // will cause computation issues with - // setting the icon, so we prevent it - this.triggerIcon = "discourse-sparkles"; - } - } - - -} diff --git a/assets/javascripts/discourse/components/thumbnail-suggestion-item.gjs b/assets/javascripts/discourse/components/thumbnail-suggestion-item.gjs deleted file mode 100644 index 8a6f9325..00000000 --- a/assets/javascripts/discourse/components/thumbnail-suggestion-item.gjs +++ /dev/null @@ -1,43 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { action } from "@ember/object"; -import DButton from "discourse/components/d-button"; - -export default class ThumbnailSuggestionItem extends Component { - @tracked selected = false; - @tracked selectIcon = "far-circle"; - @tracked selectLabel = "discourse_ai.ai_helper.thumbnail_suggestions.select"; - - @action - toggleSelection(thumbnail) { - if (this.selected) { - this.selectIcon = "far-circle"; - this.selectLabel = "discourse_ai.ai_helper.thumbnail_suggestions.select"; - this.selected = false; - return this.args.removeSelection(thumbnail); - } - - this.selectIcon = "circle-check"; - this.selectLabel = "discourse_ai.ai_helper.thumbnail_suggestions.selected"; - this.selected = true; - return this.args.addSelection(thumbnail); - } - - -} diff --git a/assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs b/assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs deleted file mode 100644 index ea00263c..00000000 --- a/assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs +++ /dev/null @@ -1,17 +0,0 @@ -import Component from "@glimmer/component"; -import { LinkTo } from "@ember/routing"; -import { i18n } from "discourse-i18n"; - -export default class AISentimentDashboard extends Component { - static shouldRender(_outletArgs, helper) { - return helper.siteSettings.ai_sentiment_enabled; - } - - -} diff --git a/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs deleted file mode 100644 index b756a2af..00000000 --- a/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs +++ /dev/null @@ -1,26 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester"; -import { showComposerAiHelper } from "../../lib/show-ai-helper"; - -export default class AiCategorySuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showComposerAiHelper( - outletArgs?.composer, - helper.siteSettings, - helper.currentUser, - "suggestions" - ); - } - - @service composer; - - -} diff --git a/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs deleted file mode 100644 index 9f02ed19..00000000 --- a/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs +++ /dev/null @@ -1,18 +0,0 @@ -import Component from "@glimmer/component"; -import AiTagSuggester from "../../components/suggestion-menus/ai-tag-suggester"; -import { showComposerAiHelper } from "../../lib/show-ai-helper"; - -export default class AiTagSuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showComposerAiHelper( - outletArgs?.composer, - helper.siteSettings, - helper.currentUser, - "suggestions" - ); - } - - -} diff --git a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs deleted file mode 100644 index df1bbd9d..00000000 --- a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs +++ /dev/null @@ -1,20 +0,0 @@ -import Component from "@glimmer/component"; -import AiTitleSuggester from "../../components/suggestion-menus/ai-title-suggester"; -import { showComposerAiHelper } from "../../lib/show-ai-helper"; - -export default class AiTitleSuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showComposerAiHelper( - outletArgs?.composer, - helper.siteSettings, - helper.currentUser, - "suggestions" - ); - } - - -} diff --git a/assets/javascripts/discourse/connectors/after-d-editor/composer-open.hbs b/assets/javascripts/discourse/connectors/after-d-editor/composer-open.hbs deleted file mode 100644 index ed0fe928..00000000 --- a/assets/javascripts/discourse/connectors/after-d-editor/composer-open.hbs +++ /dev/null @@ -1,8 +0,0 @@ -{{#if this.isAiBotChat}} - {{body-class this.aiBotClasses}} - {{#if this.renderChatWarning}} -
{{i18n - "discourse_ai.ai_bot.pm_warning" - }}
- {{/if}} -{{/if}} \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/after-d-editor/composer-open.js b/assets/javascripts/discourse/connectors/after-d-editor/composer-open.js deleted file mode 100644 index b979ac85..00000000 --- a/assets/javascripts/discourse/connectors/after-d-editor/composer-open.js +++ /dev/null @@ -1,45 +0,0 @@ -import Component from "@glimmer/component"; -import { computed } from "@ember/object"; -import { service } from "@ember/service"; -import { i18n } from "discourse-i18n"; - -export default class extends Component { - @service currentUser; - @service siteSettings; - - get composerModel() { - return this.args?.outletArgs?.composer; - } - - get renderChatWarning() { - return this.siteSettings.ai_bot_enable_chat_warning; - } - - @computed("composerModel.targetRecipients", "composerModel.title") - get aiBotClasses() { - if ( - this.composerModel?.title === - i18n("discourse_ai.ai_bot.default_pm_prefix") - ) { - return "ai-bot-chat"; - } else { - return "ai-bot-pm"; - } - } - - @computed("composerModel.targetRecipients") - get isAiBotChat() { - if ( - this.composerModel && - this.composerModel.targetRecipients && - this.currentUser.ai_enabled_chat_bots - ) { - let recipients = this.composerModel.targetRecipients.split(","); - - return this.currentUser.ai_enabled_chat_bots.any((bot) => - recipients.any((username) => username === bot.username) - ); - } - return false; - } -} diff --git a/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs b/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs deleted file mode 100644 index f226ef11..00000000 --- a/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs +++ /dev/null @@ -1,13 +0,0 @@ -import icon from "discourse/helpers/d-icon"; -import { i18n } from "discourse-i18n"; - -const SearchResultDecoration = ; - -export default SearchResultDecoration; diff --git a/assets/javascripts/discourse/connectors/before-create-topic-button/topic-list-gist-toggle.gjs b/assets/javascripts/discourse/connectors/before-create-topic-button/topic-list-gist-toggle.gjs deleted file mode 100644 index 5358ec8c..00000000 --- a/assets/javascripts/discourse/connectors/before-create-topic-button/topic-list-gist-toggle.gjs +++ /dev/null @@ -1,13 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import AiGistToggle from "../../components/ai-gist-toggle"; - -export default class AiTopicGist extends Component { - @service topicThumbnails; // avoid Topic Thumbnails theme component - - get shouldShow() { - return !this.topicThumbnails?.enabledForRoute; - } - - -} diff --git a/assets/javascripts/discourse/connectors/composer-after-save-or-cancel/ai-image-caption-loader.gjs b/assets/javascripts/discourse/connectors/composer-after-save-or-cancel/ai-image-caption-loader.gjs deleted file mode 100644 index 0e1b6e60..00000000 --- a/assets/javascripts/discourse/connectors/composer-after-save-or-cancel/ai-image-caption-loader.gjs +++ /dev/null @@ -1,19 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import loadingSpinner from "discourse/helpers/loading-spinner"; -import { i18n } from "discourse-i18n"; - -export default class AiImageCaptionLoader extends Component { - @service imageCaptionPopup; - - -} diff --git a/assets/javascripts/discourse/connectors/composer-fields/persona-llm-selector.gjs b/assets/javascripts/discourse/connectors/composer-fields/persona-llm-selector.gjs deleted file mode 100644 index 3eaaaf71..00000000 --- a/assets/javascripts/discourse/connectors/composer-fields/persona-llm-selector.gjs +++ /dev/null @@ -1,47 +0,0 @@ -import Component from "@glimmer/component"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import AiPersonaLlmSelector from "discourse/plugins/discourse-ai/discourse/components/ai-persona-llm-selector"; - -function isBotMessage(composer, currentUser) { - if ( - composer && - composer.targetRecipients && - currentUser.ai_enabled_chat_bots - ) { - const reciepients = composer.targetRecipients.split(","); - - return currentUser.ai_enabled_chat_bots - .filter((bot) => bot.username) - .any((bot) => reciepients.any((username) => username === bot.username)); - } - return false; -} - -export default class BotSelector extends Component { - static shouldRender(args, container) { - return ( - container?.currentUser?.ai_enabled_personas && - isBotMessage(args.model, container.currentUser) - ); - } - - @service currentUser; - - @action - setPersonaIdOnComposer(id) { - this.args.outletArgs.model.metaData = { ai_persona_id: id }; - } - - @action - setTargetRecipientsOnComposer(username) { - this.args.outletArgs.model.set("targetRecipients", username); - } - - -} diff --git a/assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs b/assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs deleted file mode 100644 index 1dcf3483..00000000 --- a/assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs +++ /dev/null @@ -1,21 +0,0 @@ -import Component from "@glimmer/component"; -import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester"; -import { showComposerAiHelper } from "../../lib/show-ai-helper"; - -export default class AiCategorySuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showComposerAiHelper( - outletArgs?.composer, - helper.siteSettings, - helper.currentUser, - "suggestions" - ); - } - - -} diff --git a/assets/javascripts/discourse/connectors/edit-topic-tags__after/ai-tag-suggestion.gjs b/assets/javascripts/discourse/connectors/edit-topic-tags__after/ai-tag-suggestion.gjs deleted file mode 100644 index 7404822b..00000000 --- a/assets/javascripts/discourse/connectors/edit-topic-tags__after/ai-tag-suggestion.gjs +++ /dev/null @@ -1,18 +0,0 @@ -import Component from "@glimmer/component"; -import AiTagSuggester from "../../components/suggestion-menus/ai-tag-suggester"; -import { showComposerAiHelper } from "../../lib/show-ai-helper"; - -export default class AiCategorySuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showComposerAiHelper( - outletArgs?.composer, - helper.siteSettings, - helper.currentUser, - "suggestions" - ); - } - - -} diff --git a/assets/javascripts/discourse/connectors/edit-topic-title__after/ai-title-suggestion.gjs b/assets/javascripts/discourse/connectors/edit-topic-title__after/ai-title-suggestion.gjs deleted file mode 100644 index c17f5a4d..00000000 --- a/assets/javascripts/discourse/connectors/edit-topic-title__after/ai-title-suggestion.gjs +++ /dev/null @@ -1,18 +0,0 @@ -import Component from "@glimmer/component"; -import AiTitleSuggester from "../../components/suggestion-menus/ai-title-suggester"; -import { showComposerAiHelper } from "../../lib/show-ai-helper"; - -export default class AiTitleSuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showComposerAiHelper( - outletArgs?.composer, - helper.siteSettings, - helper.currentUser, - "suggestions" - ); - } - - -} diff --git a/assets/javascripts/discourse/connectors/editor-preview/ai-image-caption-container.gjs b/assets/javascripts/discourse/connectors/editor-preview/ai-image-caption-container.gjs deleted file mode 100644 index e6860741..00000000 --- a/assets/javascripts/discourse/connectors/editor-preview/ai-image-caption-container.gjs +++ /dev/null @@ -1,94 +0,0 @@ -import Component from "@glimmer/component"; -import { on } from "@ember/modifier"; -import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import didUpdate from "@ember/render-modifiers/modifiers/did-update"; -import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; -import { service } from "@ember/service"; -import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; -import DButton from "discourse/components/d-button"; -import DTextarea from "discourse/components/d-textarea"; -import icon from "discourse/helpers/d-icon"; -import autoFocus from "discourse/modifiers/auto-focus"; -import { i18n } from "discourse-i18n"; - -export default class AiImageCaptionContainer extends Component { - @service imageCaptionPopup; - - @action - updateCaption(event) { - event.preventDefault(); - this.imageCaptionPopup.newCaption = event.target.value; - } - - @action - saveCaption() { - this.imageCaptionPopup.updateCaption(); - this.hidePopup(); - } - - @action - resizeTextarea(target) { - const style = window.getComputedStyle(target); - - // scrollbars will show based on scrollHeight alone - // so we need to consider borders too - const borderTopWidth = parseInt(style.borderTopWidth, 10); - const borderBottomWidth = parseInt(style.borderBottomWidth, 10); - - target.scrollTop = 0; - target.style.height = `${ - target.scrollHeight + borderTopWidth + borderBottomWidth - }px`; - } - - @action - hidePopup() { - this.imageCaptionPopup.showPopup = false; - if (this.imageCaptionPopup._request) { - this.imageCaptionPopup._request.abort(); - this.imageCaptionPopup._request = null; - this.imageCaptionPopup.toggleLoadingState(false); - } - } - - -} diff --git a/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs b/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs deleted file mode 100644 index 4da354cf..00000000 --- a/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs +++ /dev/null @@ -1,68 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { showPostAIHelper } from "../../lib/show-ai-helper"; - -export default class AiEditSuggestionButton extends Component { - static shouldRender(outletArgs, helper) { - return showPostAIHelper(outletArgs, helper); - } - - @service currentUser; - - @tracked loading = false; - @tracked suggestion = ""; - @tracked _activeAIRequest = null; - - get disabled() { - return this.loading || this.suggestion?.length > 0; - } - - get mode() { - return this.currentUser?.ai_helper_prompts.find( - (prompt) => prompt.name === "proofread" - ); - } - - @action - suggest() { - this.loading = true; - this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", { - method: "POST", - data: { - mode: this.mode.name, - text: this.args.outletArgs.initialValue, - custom_prompt: "", - }, - }); - - this._activeAIRequest - .then(({ suggestions }) => { - this.suggestion = suggestions[0].trim(); - this.args.outletArgs.updateValue(this.suggestion); - }) - .catch(popupAjaxError) - .finally(() => { - this.loading = false; - }); - - return this._activeAIRequest; - } - - -} diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-discobot-discoveries.gjs b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-discobot-discoveries.gjs deleted file mode 100644 index 77ef1e89..00000000 --- a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-discobot-discoveries.gjs +++ /dev/null @@ -1,53 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import bodyClass from "discourse/helpers/body-class"; -import icon from "discourse/helpers/d-icon"; -import { i18n } from "discourse-i18n"; -import AiSearchDiscoveries from "../../components/ai-search-discoveries"; -import AiSearchDiscoveriesTooltip from "../../components/ai-search-discoveries-tooltip"; - -export default class AiFullPageDiscobotDiscoveries extends Component { - static shouldRender(_args, { siteSettings, currentUser }) { - return ( - siteSettings.ai_bot_discover_persona && - currentUser?.can_use_ai_bot_discover_persona && - currentUser?.user_option?.ai_search_discoveries - ); - } - - @service capabilities; - @service discobotDiscoveries; - @service site; - - get previewLength() { - if (!this.capabilities.viewport.md) { - return 50; - } else { - return 10000; - } - } - - -} diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-search.gjs b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-search.gjs deleted file mode 100644 index 3bb1be92..00000000 --- a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-search.gjs +++ /dev/null @@ -1,17 +0,0 @@ -import Component from "@glimmer/component"; -import AiFullPageSearch from "../../components/ai-full-page-search"; - -export default class AiFullPageSearchConnector extends Component { - static shouldRender(_args, { siteSettings }) { - return siteSettings.ai_embeddings_semantic_search_enabled; - } - - -} diff --git a/assets/javascripts/discourse/connectors/post-text-buttons/ai-post-helper-trigger.gjs b/assets/javascripts/discourse/connectors/post-text-buttons/ai-post-helper-trigger.gjs deleted file mode 100644 index 2a0ec521..00000000 --- a/assets/javascripts/discourse/connectors/post-text-buttons/ai-post-helper-trigger.gjs +++ /dev/null @@ -1,154 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import { selectedRange } from "discourse/lib/utilities"; -import AiPostHelperMenu from "../../components/ai-post-helper-menu"; -import { showPostAIHelper } from "../../lib/show-ai-helper"; - -export default class AiPostHelperTrigger extends Component { - static shouldRender(outletArgs, helper) { - return showPostAIHelper(outletArgs, helper); - } - - @service menu; - - @tracked postHighlighted = false; - @tracked currentMenu = this.menu.getByIdentifier( - "post-text-selection-toolbar" - ); - - // capture the state at the moment the toolbar is rendered - // so we ensure change of state (selection change for example) - // is not impacting the menu data - menuData = { - ...this.args.outletArgs.data, - quoteState: { - buffer: this.args.outletArgs.data.quoteState.buffer, - opts: this.args.outletArgs.data.quoteState.opts, - postId: this.args.outletArgs.data.quoteState.postId, - }, - post: this.args.outletArgs.post, - selectedRange: selectedRange(), - }; - - highlightSelectedText() { - const postId = this.args.outletArgs.data.quoteState.postId; - const postElement = document.querySelector( - `article[data-post-id='${postId}'] .cooked` - ); - - if (!postElement) { - return; - } - - const range = this.menuData.selectedRange; - - // Split start/end text nodes at their range boundary - if ( - range.startContainer.nodeType === Node.TEXT_NODE && - range.startOffset > 0 - ) { - const newStartNode = range.startContainer.splitText(range.startOffset); - range.setStart(newStartNode, 0); - } - if ( - range.endContainer.nodeType === Node.TEXT_NODE && - range.endOffset < range.endContainer.length - ) { - range.endContainer.splitText(range.endOffset); - } - - // Create a Walker to traverse text nodes within range - const walker = document.createTreeWalker( - range.commonAncestorContainer, - NodeFilter.SHOW_TEXT, - { - acceptNode: (node) => - range.intersectsNode(node) - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT, - } - ); - - const textNodes = []; - - if (walker.currentNode?.nodeType === Node.TEXT_NODE) { - textNodes.push(walker.currentNode); - } else { - while (walker.nextNode()) { - textNodes.push(walker.currentNode); - } - } - - for (let textNode of textNodes) { - const highlight = document.createElement("span"); - highlight.classList.add("ai-helper-highlighted-selection"); - - // Replace textNode with highlighted clone - const clone = textNode.cloneNode(true); - highlight.appendChild(clone); - textNode.parentNode.replaceChild(highlight, textNode); - } - - window.getSelection().removeAllRanges(); - this.postHighlighted = true; - } - - removeHighlightedText() { - if (!this.postHighlighted) { - return; - } - - const highlightedSpans = document.querySelectorAll( - "span.ai-helper-highlighted-selection" - ); - - highlightedSpans.forEach((span) => { - const textNode = document.createTextNode(span.textContent); - span.parentNode.replaceChild(textNode, span); - }); - - this.postHighlighted = false; - } - - @action - async showAiPostHelperMenu() { - await this.currentMenu.close(); - - await this.menu.show(this.currentMenu.trigger, { - identifier: "ai-post-helper-menu", - component: AiPostHelperMenu, - interactive: true, - trapTab: false, - closeOnScroll: false, - modalForMobile: true, - data: this.menuData, - placement: "top-start", - fallbackPlacements: ["bottom-start"], - updateOnScroll: false, - onClose: () => { - this.removeHighlightedText(); - }, - }); - - await this.currentMenu.destroy(); - - this.highlightSelectedText(); - } - - -} diff --git a/assets/javascripts/discourse/connectors/search-menu-before-advanced-search/ai-quick-search-loader.gjs b/assets/javascripts/discourse/connectors/search-menu-before-advanced-search/ai-quick-search-loader.gjs deleted file mode 100644 index 84668acb..00000000 --- a/assets/javascripts/discourse/connectors/search-menu-before-advanced-search/ai-quick-search-loader.gjs +++ /dev/null @@ -1,15 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import loadingSpinner from "discourse/helpers/loading-spinner"; - -export default class AiQuickSearchLoader extends Component { - @service quickSearch; - - -} diff --git a/assets/javascripts/discourse/connectors/search-menu-initial-options/ai-quick-semantic-search.gjs b/assets/javascripts/discourse/connectors/search-menu-initial-options/ai-quick-semantic-search.gjs deleted file mode 100644 index a73927fd..00000000 --- a/assets/javascripts/discourse/connectors/search-menu-initial-options/ai-quick-semantic-search.gjs +++ /dev/null @@ -1,76 +0,0 @@ -import Component from "@glimmer/component"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import AssistantItem from "discourse/components/search-menu/results/assistant-item"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { isValidSearchTerm, translateResults } from "discourse/lib/search"; -import { i18n } from "discourse-i18n"; - -export default class AiQuickSemanticSearch extends Component { - static shouldRender(_args, { siteSettings }) { - return siteSettings.ai_embeddings_semantic_quick_search_enabled; - } - - @service search; - @service quickSearch; - @service siteSettings; - - @action - async searchTermChanged() { - if (!this.search.activeGlobalSearchTerm) { - this.search.noResults = false; - this.search.results = {}; - this.quickSearch.loading = false; - this.quickSearch.invalidTerm = false; - } else if ( - !isValidSearchTerm(this.search.activeGlobalSearchTerm, this.siteSettings) - ) { - this.search.noResults = true; - this.search.results = {}; - this.quickSearch.loading = false; - this.quickSearch.invalidTerm = true; - return; - } else { - await this.performSearch(); - } - } - - async performSearch() { - this.quickSearch.loading = true; - this.quickSearch.invalidTerm = false; - - try { - const results = await ajax(`/discourse-ai/embeddings/quick-search`, { - data: { - q: this.search.activeGlobalSearchTerm, - }, - }); - - const searchResults = await translateResults(results); - - if (searchResults) { - this.search.noResults = results.resultTypes.length === 0; - this.search.results = searchResults; - } - } catch (error) { - popupAjaxError(error); - } finally { - this.quickSearch.loading = false; - } - } - - -} diff --git a/assets/javascripts/discourse/connectors/search-menu-results-top/ai-discobot-discoveries.gjs b/assets/javascripts/discourse/connectors/search-menu-results-top/ai-discobot-discoveries.gjs deleted file mode 100644 index 6256656f..00000000 --- a/assets/javascripts/discourse/connectors/search-menu-results-top/ai-discobot-discoveries.gjs +++ /dev/null @@ -1,47 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import icon from "discourse/helpers/d-icon"; -import { i18n } from "discourse-i18n"; -import AiSearchDiscoveries from "../../components/ai-search-discoveries"; -import AiSearchDiscoveriesTooltip from "../../components/ai-search-discoveries-tooltip"; - -export default class AiDiscobotDiscoveries extends Component { - static shouldRender(args, { siteSettings, currentUser }) { - return ( - siteSettings.ai_bot_discover_persona && - currentUser?.can_use_ai_bot_discover_persona && - currentUser?.user_option?.ai_search_discoveries - ); - } - - @service discobotDiscoveries; - @service search; - - -} diff --git a/assets/javascripts/discourse/connectors/search-menu-results-top/ai-quick-search-info.gjs b/assets/javascripts/discourse/connectors/search-menu-results-top/ai-quick-search-info.gjs deleted file mode 100644 index 36cc54f6..00000000 --- a/assets/javascripts/discourse/connectors/search-menu-results-top/ai-quick-search-info.gjs +++ /dev/null @@ -1,31 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import { isValidSearchTerm } from "discourse/lib/search"; -import { i18n } from "discourse-i18n"; - -export default class AiQuickSearchInfo extends Component { - @service search; - @service siteSettings; - @service quickSearch; - - get termTooShort() { - // We check the validity again here because the input may have changed - // since the last time we checked, so we may want to stop showing the error - const validity = !isValidSearchTerm( - this.search.activeGlobalSearchTerm, - this.siteSettings - ); - - return ( - validity && - this.quickSearch.invalidTerm && - this.search.activeGlobalSearchTerm?.length > 0 - ); - } - - -} diff --git a/assets/javascripts/discourse/connectors/split-new-topic-category-after/ai-category-suggestion.gjs b/assets/javascripts/discourse/connectors/split-new-topic-category-after/ai-category-suggestion.gjs deleted file mode 100644 index 03c146f5..00000000 --- a/assets/javascripts/discourse/connectors/split-new-topic-category-after/ai-category-suggestion.gjs +++ /dev/null @@ -1,22 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester"; -import { showPostAIHelper } from "../../lib/show-ai-helper"; - -export default class AiCategorySuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showPostAIHelper(outletArgs, helper); - } - - @service siteSettings; - - -} diff --git a/assets/javascripts/discourse/connectors/split-new-topic-tag-after/ai-tag-suggestion.gjs b/assets/javascripts/discourse/connectors/split-new-topic-tag-after/ai-tag-suggestion.gjs deleted file mode 100644 index 09d17656..00000000 --- a/assets/javascripts/discourse/connectors/split-new-topic-tag-after/ai-tag-suggestion.gjs +++ /dev/null @@ -1,23 +0,0 @@ -import Component from "@glimmer/component"; -import { service } from "@ember/service"; -import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester"; -import { showPostAIHelper } from "../../lib/show-ai-helper"; - -export default class AiTagSuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showPostAIHelper(outletArgs, helper); - } - - @service siteSettings; - - -} diff --git a/assets/javascripts/discourse/connectors/split-new-topic-title-after/ai-title-suggestion.gjs b/assets/javascripts/discourse/connectors/split-new-topic-title-after/ai-title-suggestion.gjs deleted file mode 100644 index c5049b53..00000000 --- a/assets/javascripts/discourse/connectors/split-new-topic-title-after/ai-title-suggestion.gjs +++ /dev/null @@ -1,17 +0,0 @@ -import Component from "@glimmer/component"; -import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester"; -import { showPostAIHelper } from "../../lib/show-ai-helper"; - -export default class AiTitleSuggestion extends Component { - static shouldRender(outletArgs, helper) { - return showPostAIHelper(outletArgs, helper); - } - - -} diff --git a/assets/javascripts/discourse/connectors/topic-list-before-category/ai-topic-gist-placement.gjs b/assets/javascripts/discourse/connectors/topic-list-before-category/ai-topic-gist-placement.gjs deleted file mode 100644 index e7ddaf78..00000000 --- a/assets/javascripts/discourse/connectors/topic-list-before-category/ai-topic-gist-placement.gjs +++ /dev/null @@ -1,11 +0,0 @@ -import Component from "@glimmer/component"; -import AiTopicGist from "../../components/ai-topic-gist"; - -export default class AiTopicGistPlacement extends Component { - static shouldRender(_outletArgs, helper) { - const settings = helper.siteSettings; - return settings.discourse_ai_enabled && settings.ai_summarization_enabled; - } - - -} diff --git a/assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-trigger.gjs b/assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-trigger.gjs deleted file mode 100644 index 41528a29..00000000 --- a/assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-trigger.gjs +++ /dev/null @@ -1,38 +0,0 @@ -import Component from "@glimmer/component"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import DButton from "discourse/components/d-button"; -import AiSummaryModal from "../../components/modal/ai-summary-modal"; - -export default class AiSummaryTrigger extends Component { - @service modal; - - get isAiConversation() { - return this.args.outletArgs.topic.is_bot_pm; - } - - @action - openAiSummaryModal() { - this.modal.show(AiSummaryModal, { - model: { - topic: this.args.outletArgs.topic, - postStream: this.args.outletArgs.postStream, - }, - }); - } - - -} diff --git a/assets/javascripts/discourse/connectors/topic-map-participants-after/ai-conversation-invite.gjs b/assets/javascripts/discourse/connectors/topic-map-participants-after/ai-conversation-invite.gjs deleted file mode 100644 index 9da032fc..00000000 --- a/assets/javascripts/discourse/connectors/topic-map-participants-after/ai-conversation-invite.gjs +++ /dev/null @@ -1,55 +0,0 @@ -import Component from "@glimmer/component"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import { or } from "truth-helpers"; -import DButton from "discourse/components/d-button"; -import Participant from "discourse/components/header/topic/participant"; -import AddPmParticipants from "discourse/components/modal/add-pm-participants"; - -export default class AiConversationInvite extends Component { - static shouldRender(args) { - return args.topic.is_bot_pm; - } - - @service site; - @service modal; - @service header; - @service sidebarState; - - get participants() { - const participants = [ - ...(this.header.topicInfo.details?.allowed_users ?? []), - ...(this.header.topicInfo.details?.allowed_groups ?? []), - ]; - return participants; - } - - @action - showInvite() { - this.modal.show(AddPmParticipants, { - model: { - title: "discourse_ai.ai_bot.invite_ai_conversation.title", - inviteModel: this.args.outletArgs.topic, - }, - }); - } - - -} diff --git a/assets/javascripts/discourse/connectors/user-preferences-nav/ai-preferences.gjs b/assets/javascripts/discourse/connectors/user-preferences-nav/ai-preferences.gjs deleted file mode 100644 index ffbeee11..00000000 --- a/assets/javascripts/discourse/connectors/user-preferences-nav/ai-preferences.gjs +++ /dev/null @@ -1,19 +0,0 @@ -import Component from "@glimmer/component"; -import { LinkTo } from "@ember/routing"; -import dIcon from "discourse/helpers/d-icon"; -import { i18n } from "discourse-i18n"; - -export default class AutoImageCaptionSetting extends Component { - static shouldRender(outletArgs, helper) { - return helper.siteSettings.discourse_ai_enabled; - } - - -} diff --git a/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js b/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js deleted file mode 100644 index df49e39a..00000000 --- a/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js +++ /dev/null @@ -1,50 +0,0 @@ -import { computed } from "@ember/object"; -import AdminDashboardTabController from "admin/controllers/admin-dashboard-tab"; - -export default class AdminDashboardSentiment extends AdminDashboardTabController { - @computed("startDate", "endDate") - get filters() { - return { startDate: this.startDate, endDate: this.endDate }; - } - - get emotionFilters() { - return { - startDate: moment().subtract(2, "month").format("YYYY-MM-DD"), - endDate: moment().format("YYYY-MM-DD"), - }; - } - - get emotions() { - const emotions = [ - "admiration", - "amusement", - "anger", - "annoyance", - "approval", - "caring", - "confusion", - "curiosity", - "desire", - "disappointment", - "disapproval", - "disgust", - "embarrassment", - "excitement", - "fear", - "gratitude", - "grief", - "joy", - "love", - "nervousness", - "neutral", - "optimism", - "pride", - "realization", - "relief", - "remorse", - "sadness", - "surprise", - ]; - return emotions; - } -} diff --git a/assets/javascripts/discourse/controllers/preferences-ai.js b/assets/javascripts/discourse/controllers/preferences-ai.js deleted file mode 100644 index adab3654..00000000 --- a/assets/javascripts/discourse/controllers/preferences-ai.js +++ /dev/null @@ -1,73 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import Controller from "@ember/controller"; -import { action } from "@ember/object"; -import { service } from "@ember/service"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { isTesting } from "discourse/lib/environment"; - -export default class PreferencesAiController extends Controller { - @service siteSettings; - - @tracked saved = false; - - get booleanSettings() { - return [ - { - key: "auto_image_caption", - label: "discourse_ai.ai_helper.image_caption.automatic_caption_setting", - settingName: "auto-image-caption", - checked: this.model.user_option.auto_image_caption, - isIncluded: (() => { - const aiHelperEnabledFeatures = - this.siteSettings.ai_helper_enabled_features.split("|"); - - return ( - this.model?.user_allowed_ai_auto_image_captions && - aiHelperEnabledFeatures.includes("image_caption") && - this.siteSettings.ai_helper_enabled - ); - })(), - }, - { - key: "ai_search_discoveries", - label: "discourse_ai.discobot_discoveries.user_setting", - settingName: "ai-search-discoveries", - checked: this.model.user_option.ai_search_discoveries, - isIncluded: (() => { - return ( - this.siteSettings.ai_bot_discover_persona && - this.model?.can_use_ai_bot_discover_persona && - this.siteSettings.ai_bot_enabled - ); - })(), - }, - ]; - } - - get userSettingAttributes() { - const attrs = []; - - this.booleanSettings.forEach((setting) => { - if (setting.isIncluded) { - attrs.push(setting.key); - } - }); - - return attrs; - } - - @action - save() { - this.saved = false; - - return this.model - .save(this.userSettingAttributes) - .then(() => { - this.saved = true; - if (!isTesting()) { - location.reload(); - } - }) - .catch(popupAjaxError); - } -} diff --git a/assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js b/assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js deleted file mode 100644 index 0c5b9213..00000000 --- a/assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js +++ /dev/null @@ -1,5 +0,0 @@ -export default function () { - this.route("discourse-ai-bot-conversations", { - path: "/discourse-ai/ai-bot/conversations", - }); -} diff --git a/assets/javascripts/discourse/discourse-ai-shared-conversation-show-route-map.js b/assets/javascripts/discourse/discourse-ai-shared-conversation-show-route-map.js deleted file mode 100644 index 4afee091..00000000 --- a/assets/javascripts/discourse/discourse-ai-shared-conversation-show-route-map.js +++ /dev/null @@ -1,5 +0,0 @@ -export default function () { - this.route("discourse-ai-shared-conversation-show", { - path: "/discourse-ai/ai-bot/shared-ai-conversations/:share_key", - }); -} diff --git a/assets/javascripts/discourse/lib/ai-bot-helper.js b/assets/javascripts/discourse/lib/ai-bot-helper.js deleted file mode 100644 index 913f0bf9..00000000 --- a/assets/javascripts/discourse/lib/ai-bot-helper.js +++ /dev/null @@ -1,102 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { getOwnerWithFallback } from "discourse/lib/get-owner"; -import Composer from "discourse/models/composer"; -import { i18n } from "discourse-i18n"; -import ShareFullTopicModal from "../components/modal/share-full-topic-modal"; - -const MAX_PERSONA_USER_ID = -1200; - -let enabledChatBotMap = null; - -function ensureBotMap() { - if (!enabledChatBotMap) { - const currentUser = getOwnerWithFallback(this).lookup( - "service:current-user" - ); - enabledChatBotMap = {}; - currentUser.ai_enabled_chat_bots.forEach((bot) => { - enabledChatBotMap[bot.id] = bot; - }); - } -} - -export function isGPTBot(user) { - if (!user) { - return; - } - - ensureBotMap(); - return !!enabledChatBotMap[user.id]; -} - -export function getBotType(user) { - if (!user) { - return; - } - - ensureBotMap(); - const bot = enabledChatBotMap[user.id]; - if (!bot) { - return; - } - return bot.is_persona ? "persona" : "llm"; -} - -export function isPostFromAiBot(post, currentUser) { - return ( - post.user_id <= MAX_PERSONA_USER_ID || - !!currentUser?.ai_enabled_chat_bots?.any( - (bot) => post.username === bot.username - ) - ); -} - -export function showShareConversationModal(modal, topicId) { - ajax(`/discourse-ai/ai-bot/shared-ai-conversations/preview/${topicId}.json`) - .then((payload) => { - modal.show(ShareFullTopicModal, { model: payload }); - }) - .catch(popupAjaxError); -} - -export async function composeAiBotMessage( - targetBot, - composer, - options = { - skipFocus: false, - topicBody: "", - personaUsername: null, - } -) { - const currentUser = composer.currentUser; - const draftKey = "new_private_message_ai_" + new Date().getTime(); - - let botUsername; - if (targetBot) { - botUsername = currentUser.ai_enabled_chat_bots.find( - (bot) => bot.model_name === targetBot - )?.username; - } else if (options.personaUsername) { - botUsername = options.personaUsername; - } else { - botUsername = currentUser.ai_enabled_chat_bots[0].username; - } - - const data = { - action: Composer.PRIVATE_MESSAGE, - recipients: botUsername, - topicTitle: i18n("discourse_ai.ai_bot.default_pm_prefix"), - archetypeId: "private_message", - draftKey, - hasGroups: false, - warningsDisabled: true, - }; - - if (options.skipFocus) { - data.topicBody = options.topicBody; - await composer.open(data); - } else { - composer.focusComposer({ fallbackToNewTopic: true, openOpts: data }); - } -} diff --git a/assets/javascripts/discourse/lib/ai-helper-suggestions.js b/assets/javascripts/discourse/lib/ai-helper-suggestions.js deleted file mode 100644 index 795cab95..00000000 --- a/assets/javascripts/discourse/lib/ai-helper-suggestions.js +++ /dev/null @@ -1,34 +0,0 @@ -import { getOwner } from "@ember/application"; -import { later } from "@ember/runloop"; -import { i18n } from "discourse-i18n"; - -export const MIN_CHARACTER_COUNT = 40; - -export function showSuggestionsError(context, reloadFn) { - const toasts = getOwner(context).lookup("service:toasts"); - - toasts.error({ - class: "ai-suggestion-error", - duration: "long", - showProgressBar: true, - data: { - message: i18n("discourse_ai.ai_helper.suggest_errors.no_suggestions"), - actions: [ - { - label: i18n("discourse_ai.ai_helper.context_menu.regen"), - icon: "rotate", - class: "btn btn-small", - action: async (toast) => { - toast.close(); - - await reloadFn(); - - if (context.dMenu?.show && context.suggestions?.length > 0) { - later(() => context.dMenu.show(), 50); - } - }, - }, - ], - }, - }); -} diff --git a/assets/javascripts/discourse/lib/ai-streamer/progress-handlers.js b/assets/javascripts/discourse/lib/ai-streamer/progress-handlers.js deleted file mode 100644 index d5dea6f4..00000000 --- a/assets/javascripts/discourse/lib/ai-streamer/progress-handlers.js +++ /dev/null @@ -1,213 +0,0 @@ -import { later } from "@ember/runloop"; -import PostUpdater from "./updaters/post-updater"; - -const PROGRESS_INTERVAL = 40; -const GIVE_UP_INTERVAL = 600000; // 10 minutes which is our max thinking time for now -export const MIN_LETTERS_PER_INTERVAL = 6; -const MAX_FLUSH_TIME = 800; - -let progressTimer = null; - -/** - * Finds the last non-empty child element or text node of a given DOM element. - * Iterates backward through the element's child nodes and skips over empty text nodes. - * - * @param {HTMLElement} element - The DOM element to inspect. - * @returns {Node} - The last non-empty child node or null if none found. - */ -function lastNonEmptyChild(element) { - let lastChild = element.lastChild; - while ( - lastChild && - lastChild.nodeType === Node.TEXT_NODE && - !/\S/.test(lastChild.textContent) - ) { - lastChild = lastChild.previousSibling; - } - return lastChild; -} - -/** - * Adds a progress dot (a span element with a "progress-dot" class) at the end of the - * last non-empty block within a given DOM element. This is used to visually indicate - * progress while content is being streamed. - * - * @param {HTMLElement} element - The DOM element to which the progress dot will be added. - */ -export function addProgressDot(element) { - let lastBlock = element; - - while (true) { - let lastChild = lastNonEmptyChild(lastBlock); - if (!lastChild) { - break; - } - - if (lastChild.nodeType === Node.ELEMENT_NODE) { - lastBlock = lastChild; - } else { - break; - } - } - - const dotElement = document.createElement("span"); - dotElement.classList.add("progress-dot"); - lastBlock.appendChild(dotElement); -} - -/** - * Applies progress to a streaming operation, updating the raw and cooked text, - * handling progress dots, and stopping streaming when complete. - * - * @param {Object} status - The current streaming status object. - * @param {Object} updater - An instance of a stream updater (e.g., PostUpdater or SummaryUpdater). - * @returns {Promise} - Resolves to true if streaming is complete, otherwise false. - */ -export async function applyProgress(status, updater) { - status.startTime = status.startTime || Date.now(); - - if (Date.now() - status.startTime > GIVE_UP_INTERVAL) { - updater.streaming = false; - return true; - } - - if (!updater.element) { - // wait till later - return false; - } - - const oldRaw = updater.raw; - if (status.raw === oldRaw && !status.done) { - const hasProgressDot = updater.element.querySelector(".progress-dot"); - if (hasProgressDot) { - return false; - } - } - - if (status.raw !== undefined) { - let newRaw = status.raw; - - if (!status.done) { - // rush update if we have a tag (function call) - if (oldRaw.length === 0 && newRaw.indexOf("") !== -1) { - newRaw = status.raw; - } else { - const diff = newRaw.length - oldRaw.length; - - // progress interval is 40ms - // by default we add 6 letters per interval - // but ... we want to be done in MAX_FLUSH_TIME - let letters = Math.floor(diff / (MAX_FLUSH_TIME / PROGRESS_INTERVAL)); - if (letters < MIN_LETTERS_PER_INTERVAL) { - letters = MIN_LETTERS_PER_INTERVAL; - } - - newRaw = status.raw.substring(0, oldRaw.length + letters); - } - } - - await updater.setRaw(newRaw, status.done); - } - - if (status.done) { - if (status.cooked) { - await updater.setCooked(status.cooked); - } - updater.streaming = false; - } - - return status.done; -} - -/** - * Handles progress updates for a post stream by applying the streaming status of - * each post and updating its content accordingly. This function ensures that progress - * is tracked and handled for multiple posts simultaneously. - * - * @param {Object} postStream - The post stream object containing the posts to be updated. - * @returns {Promise} - Resolves to true if polling should continue, otherwise false. - */ -async function handleProgress(postStream) { - const status = postStream.aiStreamingStatus; - - let keepPolling = false; - - const promises = Object.keys(status).map(async (postId) => { - let postStatus = status[postId]; - - const postUpdater = new PostUpdater(postStream, postStatus.post_id); - const done = await applyProgress(postStatus, postUpdater); - - if (done) { - delete status[postId]; - } else { - keepPolling = true; - } - }); - - await Promise.all(promises); - return keepPolling; -} - -/** - * Ensures that progress for a post stream is being updated. It starts a progress timer - * if one is not already active, and continues polling for progress updates at regular intervals. - * - * @param {Object} postStream - The post stream object containing the posts to be updated. - */ -function ensureProgress(postStream) { - if (!progressTimer) { - progressTimer = later(async () => { - const keepPolling = await handleProgress(postStream); - - progressTimer = null; - - if (keepPolling) { - ensureProgress(postStream); - } - }, PROGRESS_INTERVAL); - } -} - -/** - * Streams the raw text for a post by tracking its status and applying progress updates. - * If streaming is already in progress, this function ensures it continues to update the content. - * - * @param {Object} postStream - The post stream object containing the post to be updated. - * @param {Object} data - The data object containing raw and cooked content of the post. - */ -export function streamPostText(postStream, data) { - if (data.noop) { - return; - } - - let status = (postStream.aiStreamingStatus = - postStream.aiStreamingStatus || {}); - status[data.post_id] = data; - ensureProgress(postStream); -} - -/** - * A generalized function to handle streaming of content using any specified updater class. - * It applies progress updates to the content (raw and cooked) based on the given data. - * Use this function to stream content for Glimmer components. - * - * @param {Function} updaterClass - The updater class to be used for streaming (e.g., PostUpdater, SummaryUpdater). - * @param {Object} data - The data object containing the content to be streamed. - * @param {Object} context - Additional context required for the updater (typically the context of the Ember component). - */ -export default function streamUpdaterText(updaterClass, data, context) { - const updaterInstance = new updaterClass(data, context); - - if (!progressTimer) { - progressTimer = later(async () => { - await applyProgress(data, updaterInstance); - - progressTimer = null; - - if (!data.done) { - await applyProgress(data, updaterInstance); - } - }, PROGRESS_INTERVAL); - } -} diff --git a/assets/javascripts/discourse/lib/ai-streamer/updaters/post-updater.js b/assets/javascripts/discourse/lib/ai-streamer/updaters/post-updater.js deleted file mode 100644 index 3427eb4c..00000000 --- a/assets/javascripts/discourse/lib/ai-streamer/updaters/post-updater.js +++ /dev/null @@ -1,78 +0,0 @@ -import loadMorphlex from "discourse/lib/load-morphlex"; -import { cook } from "discourse/lib/text"; -import { addProgressDot } from "../progress-handlers"; -import StreamUpdater from "./stream-updater"; - -export default class PostUpdater extends StreamUpdater { - morphingOptions = { - beforeAttributeUpdated: (element, attributeName) => { - return !(element.tagName === "DETAILS" && attributeName === "open"); - }, - }; - - constructor(postStream, postId) { - super(); - - this.postStream = postStream; - this.postId = postId; - this.post = postStream.findLoadedPost(postId); - const topicId = postStream.topic.id; - - if (this.post) { - this.postElement = document.querySelector( - `.topic-area[data-topic-id="${topicId}"] #post_${this.post.post_number}` - ); - } - } - - get element() { - return this.postElement; - } - - set streaming(value) { - if (this.postElement) { - if (value) { - this.postElement.classList.add("streaming"); - } else { - this.postElement.classList.remove("streaming"); - } - } - } - - async setRaw(value, done) { - this.post.set("raw", value); - const cooked = await cook(value); - - // resets animation - this.element.classList.remove("streaming"); - void this.element.offsetWidth; - this.element.classList.add("streaming"); - - const cookedElement = document.createElement("div"); - cookedElement.innerHTML = cooked; - - if (!done) { - addProgressDot(cookedElement); - } - - await this.setCooked(cookedElement.innerHTML); - } - - async setCooked(value) { - if (!this.postElement) { - return; - } - - this.post.set("cooked", value); - - (await loadMorphlex()).morphInner( - this.postElement.querySelector(".cooked"), - `
${value}
`, - this.morphingOptions - ); - } - - get raw() { - return this.post.get("raw") || ""; - } -} diff --git a/assets/javascripts/discourse/lib/ai-streamer/updaters/stream-updater.js b/assets/javascripts/discourse/lib/ai-streamer/updaters/stream-updater.js deleted file mode 100644 index 0bf3fbaf..00000000 --- a/assets/javascripts/discourse/lib/ai-streamer/updaters/stream-updater.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Interface needed to implement for a streaming updater - */ -export default class StreamUpdater { - set streaming(value) { - throw "not implemented"; - } - - async setCooked() { - throw "not implemented"; - } - - async setRaw() { - throw "not implemented"; - } - - get element() { - throw "not implemented"; - } - - get raw() { - throw "not implemented"; - } -} diff --git a/assets/javascripts/discourse/lib/ai-topic-summary.js b/assets/javascripts/discourse/lib/ai-topic-summary.js deleted file mode 100644 index fd2450cf..00000000 --- a/assets/javascripts/discourse/lib/ai-topic-summary.js +++ /dev/null @@ -1,72 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import { ajax } from "discourse/lib/ajax"; -import { shortDateNoYear } from "discourse/lib/formatter"; -import { cook } from "discourse/lib/text"; - -export default class AiTopicSummary { - @tracked text = ""; - @tracked summarizedOn = null; - @tracked summarizedBy = null; - @tracked newPostsSinceSummary = null; - @tracked outdated = false; - @tracked canRegenerate = false; - @tracked regenerated = false; - - @tracked showSummaryBox = false; - @tracked canCollapseSummary = false; - @tracked loadingSummary = false; - - processUpdate(update) { - const topicSummary = update.ai_topic_summary; - - return cook(topicSummary.summarized_text) - .then((cooked) => { - this.text = cooked; - this.loading = false; - }) - .then(() => { - if (update.done) { - this.summarizedOn = shortDateNoYear(topicSummary.summarized_on); - this.summarizedBy = topicSummary.algorithm; - this.newPostsSinceSummary = topicSummary.new_posts_since_summary; - this.outdated = topicSummary.outdated; - this.newPostsSinceSummary = topicSummary.new_posts_since_summary; - this.canRegenerate = - topicSummary.outdated && topicSummary.can_regenerate; - } - }); - } - - collapse() { - this.showSummaryBox = false; - this.canCollapseSummary = false; - } - - generateSummary(currentUser, topicId) { - this.showSummaryBox = true; - - if (this.text && !this.canRegenerate) { - this.canCollapseSummary = false; - return; - } - - let fetchURL = `/discourse-ai/summarization/t/${topicId}?`; - - if (currentUser) { - fetchURL += `stream=true`; - - if (this.canRegenerate) { - fetchURL += "&skip_age_check=true"; - } - } - - this.loading = true; - - return ajax(fetchURL).then((data) => { - if (!currentUser) { - data.done = true; - this.processUpdate(data); - } - }); - } -} diff --git a/assets/javascripts/discourse/lib/copy-conversation.js b/assets/javascripts/discourse/lib/copy-conversation.js deleted file mode 100644 index 7382c494..00000000 --- a/assets/javascripts/discourse/lib/copy-conversation.js +++ /dev/null @@ -1,60 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import { clipboardCopyAsync } from "discourse/lib/utilities"; -import { i18n } from "discourse-i18n"; - -export default async function (topic, fromPostNumber, toPostNumber) { - await clipboardCopyAsync(async () => { - const text = await generateClipboard(topic, fromPostNumber, toPostNumber); - return new Blob([text], { - type: "text/plain", - }); - }); -} - -async function generateClipboard(topic, fromPostNumber, toPostNumber) { - const stream = topic.get("postStream"); - - let postNumbers = []; - // simpler to understand than Array.from - for (let i = fromPostNumber; i <= toPostNumber; i++) { - postNumbers.push(i); - } - - const postIds = postNumbers.map((postNumber) => { - return stream.findPostIdForPostNumber(postNumber); - }); - - // we need raw to construct so post stream will not help - - const url = `/t/${topic.id}/posts.json`; - const data = { - post_ids: postIds, - include_raw: true, - }; - - const response = await ajax(url, { data }); - - let buffer = []; - buffer.push("
"); - buffer.push(""); - buffer.push(`${topic.title}`); - buffer.push( - `${i18n( - "discourse_ai.ai_bot.ai_label" - )}` - ); - buffer.push(""); - - response.post_stream.posts.forEach((post) => { - buffer.push(""); - buffer.push(`**${post.username}:**`); - buffer.push(""); - buffer.push(post.raw); - }); - - buffer.push("
"); - - const text = buffer.join("\n"); - - return text; -} diff --git a/assets/javascripts/discourse/lib/diff-streamer.gjs b/assets/javascripts/discourse/lib/diff-streamer.gjs deleted file mode 100644 index 49fbb146..00000000 --- a/assets/javascripts/discourse/lib/diff-streamer.gjs +++ /dev/null @@ -1,335 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import { cancel, later } from "@ember/runloop"; -import loadJSDiff from "discourse/lib/load-js-diff"; -import { escapeExpression } from "discourse/lib/utilities"; - -const DEFAULT_CHAR_TYPING_DELAY = 10; -const STREAMING_DIFF_TRUNCATE_THRESHOLD = 0.1; -const STREAMING_DIFF_TRUNCATE_BUFFER = 10; -const RUSH_MAX_TICKS = 10; // ≤ 10 visual diff refreshes -const RUSH_TICK_INTERVAL = 100; // 100 ms between them → ≤ 1 s total - -export default class DiffStreamer { - @tracked isStreaming = false; - @tracked words = []; - @tracked lastResultText = ""; - @tracked diff = this.selectedText; - @tracked suggestion = ""; - @tracked isDone = false; - @tracked isThinking = true; - - typingTimer = null; - currentWordIndex = 0; - currentCharIndex = 0; - jsDiff = null; - - bufferedToken = null; - - rushMode = false; - rushBatchSize = 1; - rushTicksLeft = 0; - - receivedFinalUpdate = false; - - /** - * Initializes the DiffStreamer with initial text and typing delay. - * @param {string} selectedText - The original text to diff against. - * @param {number} typingDelay - (Optional) character typing delay in ms. - */ - constructor(selectedText, typingDelay) { - this.selectedText = selectedText; - this.typingDelay = typingDelay || DEFAULT_CHAR_TYPING_DELAY; - this.loadJSDiff(); - } - - /** - * Loads the jsDiff library asynchronously. - */ - async loadJSDiff() { - this.jsDiff = await loadJSDiff(); - } - - /** - * Main entry point for streaming updates from the backend. - * Handles both incremental and final updates. - * @param {object} result - The result object containing the new text and status - * @param {string} newTextKey - The key in result that holds the new text value (e.g. if the JSON is { text: "Hello", done: false }, newTextKey would be "text") - */ - async updateResult(result, newTextKey) { - if (this.receivedFinalUpdate) { - return; - } - - if (!this.jsDiff) { - await this.loadJSDiff(); - } - - this.isThinking = false; - const newText = result[newTextKey]; - const gotDoneFlag = !!result?.done; - - if (gotDoneFlag) { - this.receivedFinalUpdate = true; - if (this.typingTimer) { - cancel(this.typingTimer); - this.typingTimer = null; - } - - // flush buffered token so everything is renderable - if (this.bufferedToken) { - this.words.push(this.bufferedToken); - this.bufferedToken = null; - } - - // tokenise whatever tail we haven’t processed yet - const tail = newText.slice(this.lastResultText.length); - if (tail.length) { - this.words.push(...this.#tokenize(tail)); - } - - const charsLeft = newText.length - this.suggestion.length; - if (charsLeft <= 0) { - this.suggestion = newText; - this.diff = this.#formatDiffWithTags( - this.jsDiff.diffWordsWithSpace(this.selectedText, newText), - false - ); - this.isStreaming = false; - this.isDone = true; - return; - } - - this.rushBatchSize = Math.ceil(charsLeft / RUSH_MAX_TICKS); - this.rushTicksLeft = RUSH_MAX_TICKS; - this.rushMode = true; - this.isStreaming = true; - this.lastResultText = newText; - - this.#streamNextChar(); - return; - } - - const delta = newText.slice(this.lastResultText.length); - if (!delta) { - this.lastResultText = newText; - return; - } - - // combine any previous buffered token with new delta and retokenize - const combined = (this.bufferedToken || "") + delta; - const tokens = this.#tokenize(combined); - this.bufferedToken = tokens.pop() || null; - - if (tokens.length) { - this.words.push(...tokens); - } - - this.isStreaming = true; - if (!this.typingTimer) { - this.#streamNextChar(); - } - - this.lastResultText = newText; - } - - /** - * Resets the streamer's internal state to allow reuse. - */ - reset() { - this.diff = ""; - this.suggestion = ""; - this.lastResultText = ""; - this.words = []; - this.currentWordIndex = 0; - this.currentCharIndex = 0; - this.bufferedToken = null; - - this.isStreaming = false; - this.isDone = false; - this.receivedFinalUpdate = false; - this.isThinking = true; - - this.rushMode = false; - this.rushBatchSize = 1; - this.rushTicksLeft = 0; - - if (this.typingTimer) { - cancel(this.typingTimer); - this.typingTimer = null; - } - } - - /** - * Computes a truncated diff during streaming to avoid excessive churn. - * @param {string} original - The original text. - * @param {string} suggestion - The partially streamed suggestion. - * @returns {Array} Array of diff parts with `.added`, `.removed`, and `.value`. - */ - streamingDiff(original, suggestion) { - const max = Math.floor( - suggestion.length + - suggestion.length * STREAMING_DIFF_TRUNCATE_THRESHOLD + - STREAMING_DIFF_TRUNCATE_BUFFER - ); - const head = original.slice(0, max); - const tail = original.slice(max); - - const output = this.jsDiff.diffWordsWithSpace(head, suggestion); - - if (tail.length) { - let last = output.at(-1); - let secondLast = output.at(-2); - - if (last.added && secondLast?.removed) { - output.splice(-2, 2, last, secondLast); - last = secondLast; - } - - if (!last.removed) { - last = { added: false, removed: true, value: "" }; - output.push(last); - } - last.value += tail; - } - return output; - } - - /** - * Internal loop that emits the next character(s) to simulate typing. - * Works in both normal and rush mode. - */ - #streamNextChar() { - if (!this.isStreaming) { - return; - } - - const limit = this.rushMode ? this.rushBatchSize : 1; - let emitted = 0; - while (emitted < limit && this.currentWordIndex < this.words.length) { - const token = this.words[this.currentWordIndex]; - this.suggestion += token.charAt(this.currentCharIndex); - this.currentCharIndex++; - emitted++; - - if (this.currentCharIndex >= token.length) { - this.currentWordIndex++; - this.currentCharIndex = 0; - } - } - - let refresh = false; - if (this.rushMode) { - if (this.rushTicksLeft > 0) { - this.rushTicksLeft--; - refresh = true; - } - } else { - refresh = this.currentCharIndex === 0; - } - - if (refresh || this.currentWordIndex >= this.words.length) { - const useStreaming = - this.currentWordIndex < this.words.length || this.rushMode; - this.diff = this.#formatDiffWithTags( - useStreaming - ? this.streamingDiff(this.selectedText, this.suggestion) - : this.jsDiff.diffWordsWithSpace(this.selectedText, this.suggestion), - !this.rushMode - ); - } - - const doneStreaming = this.currentWordIndex >= this.words.length; - - if (doneStreaming) { - this.isStreaming = false; - this.rushMode = false; - this.typingTimer = null; - - if (this.receivedFinalUpdate) { - this.isDone = true; - } - } else { - const delay = this.rushMode ? RUSH_TICK_INTERVAL : this.typingDelay; - this.typingTimer = later(this, this.#streamNextChar, delay); - } - } - - /** - * Splits a string into tokens, preserving whitespace as separate entries. - * @param {string} text - The input string. - * @returns {Array} Array of tokens. - */ - #tokenize(text) { - return text.split(/(?<=\S)(?=\s)/); - } - - /** - * Wraps a chunk of text in appropriate HTML tags based on its diff type. - * @param {string} text - The text chunk. - * @param {string} type - The type: 'added', 'removed', or 'unchanged'. - * @returns {string} HTML string. - */ - #wrapChunk(text, type) { - if (type === "added") { - return `${text}`; - } - if (type === "removed") { - return /^\s+$/.test(text) ? "" : `${text}`; - } - return `${text}`; - } - - /** - * Converts a diff array into a string of HTML with highlight markup. - * @param {Array} diffArray - The array from a diff function. - * @param {boolean} highlightLastWord - Whether to highlight the last non-removed word. - * @returns {string} HTML representation of the diff. - */ - #formatDiffWithTags(diffArray, highlightLastWord = true) { - const words = []; - diffArray.forEach((part) => - (part.value.match(/\S+|\s+/g) || []).forEach((tok) => - words.push({ - text: tok, - type: part.added ? "added" : part.removed ? "removed" : "unchanged", - }) - ) - ); - - let lastIndex = -1; - if (highlightLastWord) { - for (let i = words.length - 1; i >= 0; i--) { - if (words[i].type !== "removed" && /\S/.test(words[i].text)) { - lastIndex = i; - break; - } - } - } - - const output = []; - for (let i = 0; i <= lastIndex; i++) { - let { text, type } = words[i]; - text = escapeExpression(text); - if (/^\s+$/.test(text)) { - output.push(text); - continue; - } - let chunk = this.#wrapChunk(text, type); - if (highlightLastWord && i === lastIndex) { - chunk = `${chunk}`; - } - output.push(chunk); - } - - for (let i = lastIndex + 1; i < words.length; ) { - const type = words[i].type; - let buf = ""; - while (i < words.length && words[i].type === type) { - buf += words[i++].text; - } - output.push(this.#wrapChunk(escapeExpression(buf), type)); - } - - return output.join(""); - } -} diff --git a/assets/javascripts/discourse/lib/show-ai-helper.js b/assets/javascripts/discourse/lib/show-ai-helper.js deleted file mode 100644 index b05934e1..00000000 --- a/assets/javascripts/discourse/lib/show-ai-helper.js +++ /dev/null @@ -1,29 +0,0 @@ -export function showComposerAiHelper( - composerModel, - siteSettings, - currentUser, - featureType -) { - const enableHelper = _helperEnabled(siteSettings); - const enableAssistant = currentUser.can_use_assistant; - const canShowInPM = siteSettings.ai_helper_allowed_in_pm; - const enableFeature = - siteSettings.ai_helper_enabled_features.includes(featureType); - - if (composerModel?.privateMessage) { - return enableHelper && enableAssistant && canShowInPM && enableFeature; - } - - return enableHelper && enableAssistant && enableFeature; -} - -export function showPostAIHelper(outletArgs, helper) { - return ( - _helperEnabled(helper.siteSettings) && - helper.currentUser?.can_use_assistant_in_post - ); -} - -function _helperEnabled(siteSettings) { - return siteSettings.discourse_ai_enabled && siteSettings.ai_helper_enabled; -} diff --git a/assets/javascripts/discourse/lib/smooth-streamer.gjs b/assets/javascripts/discourse/lib/smooth-streamer.gjs deleted file mode 100644 index 7ce83afa..00000000 --- a/assets/javascripts/discourse/lib/smooth-streamer.gjs +++ /dev/null @@ -1,116 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import { cancel, later } from "@ember/runloop"; - -const DEFAULT_TYPING_DELAY = 15; - -/** - * SmoothStreamer provides a typing animation effect for streamed text updates. - */ -export default class SmoothStreamer { - @tracked isStreaming = false; - @tracked streamedText = ""; - typingTimer = null; - streamedTextLength = 0; - - /** - * @param {() => string} getRealtimeText - Function to retrieve the latest realtime text - * @param {(value: string) => void} setRealtimeText - Function to update the realtime text - * @param {number} [typingDelay] - Delay (in ms) between each character reveal - */ - constructor(getRealtimeText, setRealtimeText, typingDelay) { - this.getRealtimeText = getRealtimeText; - this.setRealtimeText = setRealtimeText; - this.typingDelay = typingDelay || DEFAULT_TYPING_DELAY; - } - - /** - * Retrieves the appropriate text: either the animated stream or the full realtime text. - * @returns {string} - */ - get renderedText() { - return this.isStreaming ? this.streamedText : this.realtimeText; - } - - /** - * Retrieves the current realtime text. - * @returns {string} - */ - get realtimeText() { - return this.getRealtimeText(); - } - - /** - * Updates the realtime text. - * @param {string} value - The new text value - */ - set realtimeText(value) { - this.setRealtimeText(value); - } - - /** - * Resets the streaming state, clearing all animation progress. - */ - resetStreaming() { - this.#cancelTypingTimer(); - this.isStreaming = false; - this.streamedText = ""; - this.streamedTextLength = 0; - } - - /** - * Processes an update result (typically from MessageBus) - * either completing the stream or continuing animation. - * @param {object} result - The result object containing the new text and status - * @param {string} newTextKey - The key in result that holds the new text value (e.g. if the JSON is { text: "Hello", done: false }, newTextKey would be "text") - */ - async updateResult(result, newTextKey) { - const newText = result[newTextKey]; - - if (result?.done) { - this.streamedText = newText; - this.realtimeText = newText; - this.isStreaming = false; - - // Clear pending animations - this.#cancelTypingTimer(); - } else if (newText.length > this.realtimeText.length) { - this.realtimeText = newText; - this.isStreaming = true; - await this.#onTextUpdate(); - } - } - - /** - * Types out the next character in the streaming text. - * Private method. - */ - #typeCharacter() { - if (this.streamedTextLength < this.realtimeText.length) { - this.streamedText += this.realtimeText.charAt(this.streamedTextLength); - this.streamedTextLength++; - - this.typingTimer = later(this, this.#typeCharacter, this.typingDelay); - } else { - this.typingTimer = null; - } - } - - /** - * Handles text updates and restarts the typing animation. - * Private method. - */ - #onTextUpdate() { - this.#cancelTypingTimer(); - this.#typeCharacter(); - } - - /** - * Cancels any pending typing animation. - * Private method. - */ - #cancelTypingTimer() { - if (this.typingTimer) { - cancel(this.typingTimer); - } - } -} diff --git a/assets/javascripts/discourse/lib/utilities.js b/assets/javascripts/discourse/lib/utilities.js deleted file mode 100644 index 840a2496..00000000 --- a/assets/javascripts/discourse/lib/utilities.js +++ /dev/null @@ -1,35 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { escapeExpression } from "discourse/lib/utilities"; - -export function jsonToHtml(json) { - if (json === null) { - return "null"; - } - - if (typeof json !== "object") { - return escapeExpression(json); - } - - let html = "
    "; - - for (let [key, value] of Object.entries(json)) { - html += "
  • "; - key = escapeExpression(key); - - if (typeof value === "object" && Array.isArray(value)) { - html += `${key}: ${jsonToHtml(value)}`; - } else if (typeof value === "object") { - html += `${key}:
    • ${jsonToHtml(value)}
    `; - } else { - if (typeof value === "string") { - value = escapeExpression(value).replace(/\n/g, "
    "); - } - html += `${key}: ${value}`; - } - - html += "
  • "; - } - - html += "
"; - return htmlSafe(html); -} diff --git a/assets/javascripts/discourse/preferences-ai-route-map.js b/assets/javascripts/discourse/preferences-ai-route-map.js deleted file mode 100644 index 4583beb6..00000000 --- a/assets/javascripts/discourse/preferences-ai-route-map.js +++ /dev/null @@ -1,7 +0,0 @@ -export default { - resource: "user.preferences", - - map() { - this.route("ai"); - }, -}; diff --git a/assets/javascripts/discourse/routes/discourse-ai-shared-conversation-show.js b/assets/javascripts/discourse/routes/discourse-ai-shared-conversation-show.js deleted file mode 100644 index 7d33124d..00000000 --- a/assets/javascripts/discourse/routes/discourse-ai-shared-conversation-show.js +++ /dev/null @@ -1,19 +0,0 @@ -import { service } from "@ember/service"; -import DiscourseRoute from "discourse/routes/discourse"; - -export default class DiscourseAiSharedConversationShowRoute extends DiscourseRoute { - @service currentUser; - - beforeModel(transition) { - if (this.currentUser?.user_option?.external_links_in_new_tab) { - window.open(transition.intent.url, "_blank"); - } else { - this.redirect(transition.intent.url); - } - transition.abort(); - } - - redirect(url) { - window.location = url; - } -} diff --git a/assets/javascripts/discourse/routes/preferences-ai.js b/assets/javascripts/discourse/routes/preferences-ai.js deleted file mode 100644 index f4821814..00000000 --- a/assets/javascripts/discourse/routes/preferences-ai.js +++ /dev/null @@ -1,15 +0,0 @@ -import { service } from "@ember/service"; -import { defaultHomepage } from "discourse/lib/utilities"; -import RestrictedUserRoute from "discourse/routes/restricted-user"; - -export default class PreferencesAiRoute extends RestrictedUserRoute { - @service siteSettings; - - setupController(controller, user) { - if (!this.siteSettings.discourse_ai_enabled) { - return this.router.transitionTo(`discovery.${defaultHomepage()}`); - } - - controller.set("model", user); - } -} diff --git a/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js b/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js deleted file mode 100644 index bc7bc02e..00000000 --- a/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js +++ /dev/null @@ -1,98 +0,0 @@ -import { action } from "@ember/object"; -import { next } from "@ember/runloop"; -import Service, { service } from "@ember/service"; -import { tracked } from "@ember-compat/tracked-built-ins"; -import { ajax } from "discourse/lib/ajax"; -import { getUploadMarkdown } from "discourse/lib/uploads"; -import { i18n } from "discourse-i18n"; - -export default class AiBotConversationsHiddenSubmit extends Service { - @service aiConversationsSidebarManager; - @service appEvents; - @service composer; - @service dialog; - @service router; - @service siteSettings; - - @tracked loading = false; - - personaId; - targetUsername; - - inputValue = ""; - - @action - focusInput() { - this.composer.destroyDraft(); - this.composer.close(); - next(() => { - document.getElementById("ai-bot-conversations-input").focus(); - }); - } - - @action - async submitToBot(uploadData) { - if ( - this.inputValue.length < - this.siteSettings.min_personal_message_post_length - ) { - return this.dialog.alert({ - message: i18n( - "discourse_ai.ai_bot.conversations.min_input_length_message", - { count: this.siteSettings.min_personal_message_post_length } - ), - didConfirm: () => this.focusInput(), - didCancel: () => this.focusInput(), - }); - } - - // Don't submit if there are still uploads in progress - if (uploadData.inProgressUploadsCount > 0) { - return this.dialog.alert({ - message: i18n("discourse_ai.ai_bot.conversations.uploads_in_progress"), - }); - } - - this.loading = true; - const title = i18n("discourse_ai.ai_bot.default_pm_prefix"); - - // Prepare the raw content with any uploads appended - let rawContent = this.inputValue; - - // Append upload markdown if we have uploads - if (uploadData.uploads && uploadData.uploads.length > 0) { - rawContent += "\n\n"; - - uploadData.uploads.forEach((upload) => { - const uploadMarkdown = getUploadMarkdown(upload); - rawContent += uploadMarkdown + "\n"; - }); - } - - try { - const response = await ajax("/posts.json", { - method: "POST", - data: { - raw: rawContent, - title, - archetype: "private_message", - target_recipients: this.targetUsername, - meta_data: { ai_persona_id: this.personaId }, - }, - }); - - // Reset uploads after successful submission - this.inputValue = ""; - - this.appEvents.trigger("discourse-ai:bot-pm-created", { - id: response.topic_id, - slug: response.topic_slug, - title, - }); - - this.router.transitionTo(response.post_url); - } finally { - this.loading = false; - } - } -} diff --git a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js deleted file mode 100644 index c7260b58..00000000 --- a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js +++ /dev/null @@ -1,339 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import { scheduleOnce } from "@ember/runloop"; -import Service, { service } from "@ember/service"; -import { htmlSafe } from "@ember/template"; -import { TrackedArray } from "@ember-compat/tracked-built-ins"; -import { ajax } from "discourse/lib/ajax"; -import discourseDebounce from "discourse/lib/debounce"; -import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; -import { ADMIN_PANEL, MAIN_PANEL } from "discourse/lib/sidebar/panels"; -import { i18n } from "discourse-i18n"; -import AiBotSidebarEmptyState from "../../discourse/components/ai-bot-sidebar-empty-state"; - -export const AI_CONVERSATIONS_PANEL = "ai-conversations"; -const SCROLL_BUFFER = 100; -const DEBOUNCE = 100; - -export default class AiConversationsSidebarManager extends Service { - @service appEvents; - @service sidebarState; - @service messageBus; - - @tracked topics = []; - @tracked sections = new TrackedArray(); - @tracked isLoading = true; - - api = null; - isFetching = false; - page = 0; - hasMore = true; - _registered = new Set(); - _hasScrollListener = false; - _scrollElement = null; - _didInit = false; - - _debouncedScrollHandler = () => { - discourseDebounce( - this, - () => { - const element = this._scrollElement; - if (!element) { - return; - } - - const { scrollTop, scrollHeight, clientHeight } = element; - if ( - scrollHeight - scrollTop - clientHeight - SCROLL_BUFFER < 100 && - !this.isFetching && - this.hasMore - ) { - this.fetchMessages(); - } - }, - DEBOUNCE - ); - }; - - constructor() { - super(...arguments); - - this.appEvents.on( - "discourse-ai:bot-pm-created", - this, - this._handleNewBotPM - ); - - this.appEvents.on( - "discourse-ai:conversations-sidebar-updated", - this, - this._attachScrollListener - ); - } - - willDestroy() { - super.willDestroy(...arguments); - this.appEvents.off( - "discourse-ai:bot-pm-created", - this, - this._handleNewBotPM - ); - this.appEvents.off( - "discourse-ai:conversations-sidebar-updated", - this, - this._attachScrollListener - ); - } - - addEmptyStateClass() { - document.body.classList.toggle( - "has-empty-ai-conversations-sidebar", - !this.topics.length - ); - } - - forceCustomSidebar() { - document.body.classList.add("has-ai-conversations-sidebar"); - if (!this.sidebarState.isForcingSidebar) { - this.appEvents.trigger("discourse-ai:force-conversations-sidebar"); - } - - this.sidebarState.isForcingSidebar = true; - - // calling this before fetching data - // helps avoid flash of main sidebar mode - this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); - this.sidebarState.setSeparatedMode(); - this.sidebarState.hideSwitchPanelButtons(); - - // don't render sidebar multiple times - if (this._didInit) { - this.addEmptyStateClass(); - return true; - } - - this._didInit = true; - - this.fetchMessages().then(() => { - this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); - this.addEmptyStateClass(); - }); - - return true; - } - - _attachScrollListener() { - const sections = document.querySelector( - ".sidebar-sections.ai-conversations-panel" - ); - this._scrollElement = sections; - - if (this._hasScrollListener || !this._scrollElement) { - return; - } - - sections.addEventListener("scroll", this._debouncedScrollHandler); - - this._hasScrollListener = true; - } - - _removeScrollListener() { - if (this._hasScrollListener) { - this._scrollElement.removeEventListener( - "scroll", - this._debouncedScrollHandler - ); - this._hasScrollListener = false; - this._scrollElement = null; - } - } - - stopForcingCustomSidebar() { - document.body.classList.remove("has-ai-conversations-sidebar"); - document.body.classList.remove("has-empty-ai-conversations-sidebar"); - - const isAdmin = this.sidebarState.currentPanel?.key === ADMIN_PANEL; - if (this.sidebarState.isForcingSidebar && !isAdmin) { - this.sidebarState.setPanel(MAIN_PANEL); - this.sidebarState.isForcingSidebar = false; - this.appEvents.trigger("discourse-ai:stop-forcing-conversations-sidebar"); - } - - this._removeScrollListener(); - } - - async fetchMessages() { - if (this.isFetching || !this.hasMore) { - return; - } - - const isFirstPage = this.page === 0; - this.isFetching = true; - - try { - let { conversations, meta } = await ajax( - "/discourse-ai/ai-bot/conversations.json", - { data: { page: this.page, per_page: 40 } } - ); - - if (isFirstPage) { - this.topics = conversations; - } else { - this.topics = [...this.topics, ...conversations]; - // force rerender when fetching more messages - this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); - } - - this.page += 1; - this.hasMore = meta.has_more; - - this._rebuildSections(); - } finally { - this.isFetching = false; - this.isLoading = false; - } - } - - _handleNewBotPM(topic) { - this.topics = [topic, ...this.topics]; - this._rebuildSections(); - this._watchForTitleUpdate(topic.id); - } - - _watchForTitleUpdate(topicId) { - if (this._subscribedTopicIds?.has(topicId)) { - return; - } - - this._subscribedTopicIds = this._subscribedTopicIds || new Set(); - this._subscribedTopicIds.add(topicId); - - const channel = `/discourse-ai/ai-bot/topic/${topicId}`; - - this.messageBus.subscribe(channel, (payload) => { - this._applyTitleUpdate(topicId, payload.title); - this.messageBus.unsubscribe(channel); - }); - } - - _applyTitleUpdate(topicId, newTitle) { - this.topics = this.topics.map((t) => - t.id === topicId ? { ...t, title: newTitle } : t - ); - - this._rebuildSections(); - } - - // organize by date and create a section for each date group - _rebuildSections() { - const now = Date.now(); - const fresh = []; - - const todaySection = { - name: "today", - title: i18n("discourse_ai.ai_bot.conversations.today"), - links: new TrackedArray(), - }; - fresh.push(todaySection); - - this.topics.forEach((t) => { - const postedAtMs = new Date(t.last_posted_at || now).valueOf(); - const diffDays = Math.floor((now - postedAtMs) / 86400000); - let dateGroup; - - if (diffDays <= 1) { - dateGroup = "today"; - } else if (diffDays <= 7) { - dateGroup = "last-7-days"; - } else if (diffDays <= 30) { - dateGroup = "last-30-days"; - } else { - const d = new Date(postedAtMs); - const key = `${d.getFullYear()}-${d.getMonth()}`; - dateGroup = key; - } - - let sec; - if (dateGroup === "today") { - sec = todaySection; - } else { - sec = fresh.find((s) => s.name === dateGroup); - } - - if (!sec) { - let title; - switch (dateGroup) { - case "last-7-days": - title = i18n("discourse_ai.ai_bot.conversations.last_7_days"); - break; - case "last-30-days": - title = i18n("discourse_ai.ai_bot.conversations.last_30_days"); - break; - default: - title = autoUpdatingRelativeAge(new Date(t.last_posted_at)); - } - sec = { name: dateGroup, title, links: new TrackedArray() }; - fresh.push(sec); - } - - sec.links.push({ - key: t.id, - route: "topic.fromParamsNear", - models: [t.slug, t.id, t.last_read_post_number || 0], - title: t.title, - text: t.title, - classNames: `ai-conversation-${t.id}`, - }); - }); - - this.sections = new TrackedArray(fresh); - - // register each new section once - for (let sec of fresh) { - if (this._registered.has(sec.name)) { - continue; - } - this._registered.add(sec.name); - - this.api.addSidebarSection((BaseCustomSidebarSection) => { - return class extends BaseCustomSidebarSection { - @service("ai-conversations-sidebar-manager") manager; - @service("appEvents") events; - - constructor() { - super(...arguments); - scheduleOnce("afterRender", this, this.triggerEvent); - } - - triggerEvent() { - this.events.trigger("discourse-ai:conversations-sidebar-updated"); - } - - get name() { - return sec.name; - } - - get title() { - return sec.title; - } - - get text() { - return htmlSafe(sec.title); - } - - get links() { - return ( - this.manager.sections.find((s) => s.name === sec.name)?.links || - [] - ); - } - - get emptyStateComponent() { - if (!this.manager.isLoading && this.links.length === 0) { - return AiBotSidebarEmptyState; - } - } - }; - }, AI_CONVERSATIONS_PANEL); - } - } -} diff --git a/assets/javascripts/discourse/services/discobot-discoveries.js b/assets/javascripts/discourse/services/discobot-discoveries.js deleted file mode 100644 index 7813068c..00000000 --- a/assets/javascripts/discourse/services/discobot-discoveries.js +++ /dev/null @@ -1,37 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import Service, { service } from "@ember/service"; - -export default class DiscobotDiscoveries extends Service { - // We use this to retain state after search menu gets closed. - // Similar to discourse/discourse#25504 - @service currentUser; - - @tracked discovery = ""; - @tracked lastQuery = ""; - @tracked discoveryTimedOut = false; - @tracked modelUsed = ""; - @tracked loadingDiscoveries = false; - - resetDiscovery() { - this.loadingDiscoveries = false; - this.discovery = ""; - this.discoveryTimedOut = false; - this.modelUsed = ""; - } - - get showDiscoveryTitle() { - return ( - this.discovery.length > 0 || - this.loadingDiscoveries || - this.discoveryTimedOut - ); - } - - @action - async disableDiscoveries() { - this.currentUser.user_option.ai_search_discoveries = false; - await this.currentUser.save(["ai_search_discoveries"]); - location.reload(); - } -} diff --git a/assets/javascripts/discourse/services/gists.js b/assets/javascripts/discourse/services/gists.js deleted file mode 100644 index de939bcd..00000000 --- a/assets/javascripts/discourse/services/gists.js +++ /dev/null @@ -1,23 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import Service, { service } from "@ember/service"; - -export default class Gists extends Service { - @service router; - - @tracked preference = localStorage.getItem("topicListLayout"); - - get shouldShow() { - return this.router.currentRoute.attributes?.list?.topics?.some( - (topic) => topic.ai_topic_gist - ); - } - - setPreference(value) { - this.preference = value; - localStorage.setItem("topicListLayout", value); - - if (this.preference === "table-ai") { - localStorage.setItem("aiPreferred", true); - } - } -} diff --git a/assets/javascripts/discourse/services/image-caption-popup.js b/assets/javascripts/discourse/services/image-caption-popup.js deleted file mode 100644 index 48164070..00000000 --- a/assets/javascripts/discourse/services/image-caption-popup.js +++ /dev/null @@ -1,44 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import Service, { service } from "@ember/service"; -import { IMAGE_MARKDOWN_REGEX } from "discourse/lib/uploads"; - -export default class ImageCaptionPopup extends Service { - @service composer; - @service appEvents; - - @tracked showPopup = false; - @tracked imageIndex = null; - @tracked imageSrc = null; - @tracked newCaption = null; - @tracked loading = false; - @tracked popupTrigger = null; - @tracked showAutoCaptionLoader = false; - @tracked _request = null; - - updateCaption() { - const matchingPlaceholder = - this.composer.model.reply.match(IMAGE_MARKDOWN_REGEX); - - if (matchingPlaceholder) { - const match = matchingPlaceholder[this.imageIndex]; - const replacement = match.replace( - IMAGE_MARKDOWN_REGEX, - `![${this.newCaption}|$2$3$4]($5)` - ); - - if (match) { - this.appEvents.trigger("composer:replace-text", match, replacement); - } - } - } - - toggleLoadingState(loading) { - if (loading) { - this.popupTrigger?.classList.add("disabled"); - return (this.loading = true); - } - - this.popupTrigger?.classList.remove("disabled"); - return (this.loading = false); - } -} diff --git a/assets/javascripts/discourse/services/quick-search.js b/assets/javascripts/discourse/services/quick-search.js deleted file mode 100644 index 9ab9a3c0..00000000 --- a/assets/javascripts/discourse/services/quick-search.js +++ /dev/null @@ -1,7 +0,0 @@ -import { tracked } from "@glimmer/tracking"; -import Service from "@ember/service"; - -export default class QuickSearch extends Service { - @tracked loading = false; - @tracked invalidTerm = false; -} diff --git a/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs b/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs deleted file mode 100644 index d74ff48c..00000000 --- a/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs +++ /dev/null @@ -1,73 +0,0 @@ -
-
-
-

- {{i18n "discourse_ai.sentiments.dashboard.title"}} -

- - -
-
- -
-
- -
-
- -
-
-
-
-
-
{{i18n - "admin.dashboard.reports.today" - }}
-
{{i18n - "admin.dashboard.reports.yesterday" - }}
-
{{i18n - "admin.dashboard.reports.last_7_days" - }}
-
{{i18n - "admin.dashboard.reports.last_30_days" - }}
-
- {{#each this.emotions as |metric|}} - - {{/each}} -
-
-
-
-
-
\ No newline at end of file diff --git a/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs b/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs deleted file mode 100644 index 07c29ef1..00000000 --- a/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs +++ /dev/null @@ -1,4 +0,0 @@ -import RouteTemplate from "ember-route-template"; -import AiBotConversations from "discourse/plugins/discourse-ai/discourse/components/ai-bot-conversations"; - -export default RouteTemplate(); diff --git a/assets/javascripts/discourse/templates/preferences/ai.gjs b/assets/javascripts/discourse/templates/preferences/ai.gjs deleted file mode 100644 index e7fb86e2..00000000 --- a/assets/javascripts/discourse/templates/preferences/ai.gjs +++ /dev/null @@ -1,40 +0,0 @@ -import { get } from "@ember/helper"; -import RouteTemplate from "ember-route-template"; -import { eq } from "truth-helpers"; -import PreferenceCheckbox from "discourse/components/preference-checkbox"; -import SaveControls from "discourse/components/save-controls"; -import { i18n } from "discourse-i18n"; - -export default RouteTemplate( - -); diff --git a/assets/javascripts/initializers/admin-plugin-configuration-nav.js b/assets/javascripts/initializers/admin-plugin-configuration-nav.js deleted file mode 100644 index d0669dc2..00000000 --- a/assets/javascripts/initializers/admin-plugin-configuration-nav.js +++ /dev/null @@ -1,52 +0,0 @@ -import { withPluginApi } from "discourse/lib/plugin-api"; - -export default { - name: "discourse-ai-admin-plugin-configuration-nav", - - initialize(container) { - const currentUser = container.lookup("service:current-user"); - if (!currentUser || !currentUser.admin) { - return; - } - - withPluginApi("1.1.0", (api) => { - api.addAdminPluginConfigurationNav("discourse-ai", [ - { - label: "discourse_ai.features.short_title", - route: "adminPlugins.show.discourse-ai-features", - description: "discourse_ai.features.description", - }, - { - label: "discourse_ai.usage.short_title", - route: "adminPlugins.show.discourse-ai-usage", - description: "discourse_ai.usage.subheader_description", - }, - { - label: "discourse_ai.llms.short_title", - route: "adminPlugins.show.discourse-ai-llms", - description: "discourse_ai.llms.preconfigured.description", - }, - { - label: "discourse_ai.ai_persona.short_title", - route: "adminPlugins.show.discourse-ai-personas", - description: "discourse_ai.ai_persona.persona_description", - }, - { - label: "discourse_ai.embeddings.short_title", - route: "adminPlugins.show.discourse-ai-embeddings", - description: "discourse_ai.embeddings.description", - }, - { - label: "discourse_ai.tools.short_title", - route: "adminPlugins.show.discourse-ai-tools", - description: "discourse_ai.tools.subheader_description", - }, - { - label: "discourse_ai.spam.short_title", - route: "adminPlugins.show.discourse-ai-spam", - description: "discourse_ai.spam.spam_description", - }, - ]); - }); - }, -}; diff --git a/assets/javascripts/initializers/admin-reports.js b/assets/javascripts/initializers/admin-reports.js deleted file mode 100644 index 9fc3fc7c..00000000 --- a/assets/javascripts/initializers/admin-reports.js +++ /dev/null @@ -1,34 +0,0 @@ -import { withPluginApi } from "discourse/lib/plugin-api"; -import AdminReportEmotion from "discourse/plugins/discourse-ai/discourse/components/admin-report-emotion"; - -export default { - name: "discourse-ai-admin-reports", - - initialize(container) { - const currentUser = container.lookup("service:current-user"); - if (!currentUser || !currentUser.admin) { - return; - } - - // We need to import dynamically with CommonJS require because - // using ESM import in an initializer would cause the component to be imported globally - // and cause errors for non-admin users since the component is only available to admins - const AdminReportSentimentAnalysis = - require("discourse/plugins/discourse-ai/discourse/components/admin-report-sentiment-analysis").default; - - withPluginApi((api) => { - api.registerReportModeComponent("emotion", AdminReportEmotion); - api.registerReportModeComponent( - "sentiment_analysis", - AdminReportSentimentAnalysis - ); - - api.registerValueTransformer( - "admin-reports-show-query-params", - ({ value }) => { - return [...value, "selectedChart"]; - } - ); - }); - }, -}; diff --git a/assets/javascripts/initializers/ai-artifacts.gjs b/assets/javascripts/initializers/ai-artifacts.gjs deleted file mode 100644 index a7903188..00000000 --- a/assets/javascripts/initializers/ai-artifacts.gjs +++ /dev/null @@ -1,75 +0,0 @@ -import { withPluginApi } from "discourse/lib/plugin-api"; -import AiArtifact from "../discourse/components/ai-artifact"; - -function initializeAiArtifacts(api) { - api.decorateCookedElement( - (element, helper) => { - if (!helper.renderGlimmer) { - return; - } - - [...element.querySelectorAll("div.ai-artifact")].forEach( - (artifactElement) => { - const artifactId = artifactElement.getAttribute( - "data-ai-artifact-id" - ); - - const artifactVersion = artifactElement.getAttribute( - "data-ai-artifact-version" - ); - - const artifactHeight = artifactElement.getAttribute( - "data-ai-artifact-height" - ); - - const autorun = - artifactElement.getAttribute("data-ai-artifact-autorun") || - artifactElement.hasAttribute("data-ai-artifact-autorun"); - - const seamless = - artifactElement.getAttribute("data-ai-artifact-seamless") || - artifactElement.hasAttribute("data-ai-artifact-seamless"); - - const dataAttributes = {}; - for (const attr of artifactElement.attributes) { - if ( - attr.name.startsWith("data-") && - attr.name !== "data-ai-artifact-id" && - attr.name !== "data-ai-artifact-version" && - attr.name !== "data-ai-artifact-height" && - attr.name !== "data-ai-artifact-autorun" && - attr.name !== "data-ai-artifact-seamless" - ) { - dataAttributes[attr.name] = attr.value; - } - } - - helper.renderGlimmer( - artifactElement, - - ); - } - ); - }, - { - id: "ai-artifact", - onlyStream: true, - } - ); -} - -export default { - name: "ai-artifact", - initialize() { - withPluginApi("0.8.7", initializeAiArtifacts); - }, -}; diff --git a/assets/javascripts/initializers/ai-bot-replies.js b/assets/javascripts/initializers/ai-bot-replies.js deleted file mode 100644 index 822e6ffd..00000000 --- a/assets/javascripts/initializers/ai-bot-replies.js +++ /dev/null @@ -1,181 +0,0 @@ -import { hbs } from "ember-cli-htmlbars"; -import { withSilencedDeprecations } from "discourse/lib/deprecated"; -import { withPluginApi } from "discourse/lib/plugin-api"; -import { registerWidgetShim } from "discourse/widgets/render-glimmer"; -import AiBotHeaderIcon from "../discourse/components/ai-bot-header-icon"; -import AiPersonaFlair from "../discourse/components/post/ai-persona-flair"; -import AiCancelStreamingButton from "../discourse/components/post-menu/ai-cancel-streaming-button"; -import AiDebugButton from "../discourse/components/post-menu/ai-debug-button"; -import AiShareButton from "../discourse/components/post-menu/ai-share-button"; -import { - getBotType, - isGPTBot, - showShareConversationModal, -} from "../discourse/lib/ai-bot-helper"; -import { streamPostText } from "../discourse/lib/ai-streamer/progress-handlers"; - -let allowDebug = false; - -function attachHeaderIcon(api) { - api.headerIcons.add("ai", AiBotHeaderIcon); -} - -function initializeAIBotReplies(api) { - initializePauseButton(api); - - api.modifyClass("controller:topic", { - pluginId: "discourse-ai", - - onAIBotStreamedReply: function (data) { - streamPostText(this.model.postStream, data); - }, - subscribe: function () { - this._super(); - - if ( - this.model.isPrivateMessage && - this.model.details.allowed_users && - this.model.details.allowed_users.filter(isGPTBot).length >= 1 - ) { - // we attempt to recover the last message in the bus - // so we subscribe at -2 - this.messageBus.subscribe( - `discourse-ai/ai-bot/topic/${this.model.id}`, - this.onAIBotStreamedReply.bind(this), - -2 - ); - } - }, - unsubscribe: function () { - this.messageBus.unsubscribe("discourse-ai/ai-bot/topic/*"); - this._super(); - }, - }); -} - -function initializePersonaDecorator(api) { - api.renderAfterWrapperOutlet("post-meta-data-poster-name", AiPersonaFlair); - - withSilencedDeprecations("discourse.post-stream-widget-overrides", () => - initializeWidgetPersonaDecorator(api) - ); -} - -function initializeWidgetPersonaDecorator(api) { - api.decorateWidget(`poster-name:after`, (dec) => { - const botType = getBotType(dec.attrs.user); - // we have 2 ways of decorating - // 1. if a bot is a LLM we decorate with persona name - // 2. if bot is a persona we decorate with LLM name - if (botType === "llm") { - return dec.widget.attach("persona-flair", { - personaName: dec.model?.topic?.ai_persona_name, - }); - } else if (botType === "persona") { - return dec.widget.attach("persona-flair", { - personaName: dec.model?.llm_name, - }); - } - }); - - registerWidgetShim( - "persona-flair", - "span.persona-flair", - hbs`{{@data.personaName}}` - ); -} - -function initializePauseButton(api) { - api.registerValueTransformer( - "post-menu-buttons", - ({ value: dag, context: { post, firstButtonKey } }) => { - if (isGPTBot(post.user)) { - dag.add("ai-cancel-gpt", AiCancelStreamingButton, { - before: firstButtonKey, - after: ["ai-share", "ai-debug"], - }); - } - } - ); -} - -function initializeDebugButton(api) { - const currentUser = api.getCurrentUser(); - if (!currentUser || !currentUser.ai_enabled_chat_bots || !allowDebug) { - return; - } - - api.registerValueTransformer( - "post-menu-buttons", - ({ value: dag, context: { post, firstButtonKey } }) => { - if (post.topic?.archetype === "private_message") { - dag.add("ai-debug", AiDebugButton, { - before: firstButtonKey, - after: "ai-share", - }); - } - } - ); -} - -function initializeShareButton(api) { - const currentUser = api.getCurrentUser(); - if (!currentUser || !currentUser.ai_enabled_chat_bots) { - return; - } - - api.registerValueTransformer( - "post-menu-buttons", - ({ value: dag, context: { post, firstButtonKey } }) => { - if (post.topic?.archetype === "private_message") { - dag.add("ai-share", AiShareButton, { - before: firstButtonKey, - }); - } - } - ); -} - -function initializeShareTopicButton(api) { - const modal = api.container.lookup("service:modal"); - const currentUser = api.container.lookup("service:current-user"); - - api.registerTopicFooterButton({ - id: "share-ai-conversation", - icon: "share-nodes", - label: "discourse_ai.ai_bot.share_ai_conversation.name", - title: "discourse_ai.ai_bot.share_ai_conversation.title", - action() { - showShareConversationModal(modal, this.topic.id); - }, - classNames: ["share-ai-conversation-button"], - dependentKeys: ["topic.ai_persona_name"], - displayed() { - return ( - currentUser?.can_share_ai_bot_conversations && - this.topic.ai_persona_name - ); - }, - }); -} - -export default { - name: "discourse-ai-bot-replies", - - initialize(container) { - const user = container.lookup("service:current-user"); - - if (user?.ai_enabled_chat_bots) { - allowDebug = user.can_debug_ai_bot_conversations; - - withPluginApi((api) => { - attachHeaderIcon(api); - initializeAIBotReplies(api); - initializePersonaDecorator(api); - initializeDebugButton(api, container); - initializeShareButton(api, container); - initializeShareTopicButton(api, container); - }); - } - }, -}; diff --git a/assets/javascripts/initializers/ai-chat-summarization.js b/assets/javascripts/initializers/ai-chat-summarization.js deleted file mode 100644 index 57966c59..00000000 --- a/assets/javascripts/initializers/ai-chat-summarization.js +++ /dev/null @@ -1,30 +0,0 @@ -import { apiInitializer } from "discourse/lib/api"; -import ChatModalChannelSummary from "../discourse/components/modal/chat-modal-channel-summary"; - -export default apiInitializer("1.34.0", (api) => { - const siteSettings = api.container.lookup("service:site-settings"); - const currentUser = api.getCurrentUser(); - const chatService = api.container.lookup("service:chat"); - const modal = api.container.lookup("service:modal"); - const canSummarize = currentUser && currentUser.can_summarize; - - if ( - !siteSettings.chat_enabled || - !chatService?.userCanChat || - !canSummarize - ) { - return; - } - - api.registerChatComposerButton({ - translatedLabel: "discourse_ai.summarization.chat.title", - id: "channel-summary", - icon: "discourse-sparkles", - position: "dropdown", - action: () => { - modal.show(ChatModalChannelSummary, { - model: { channelId: chatService.activeChannel?.id }, - }); - }, - }); -}); diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js deleted file mode 100644 index de5e7305..00000000 --- a/assets/javascripts/initializers/ai-conversations-sidebar.js +++ /dev/null @@ -1,64 +0,0 @@ -import { withPluginApi } from "discourse/lib/plugin-api"; -import AiBotSidebarNewConversation from "../discourse/components/ai-bot-sidebar-new-conversation"; -import { AI_CONVERSATIONS_PANEL } from "../discourse/services/ai-conversations-sidebar-manager"; - -export default { - name: "ai-conversations-sidebar", - - initialize() { - withPluginApi((api) => { - const siteSettings = api.container.lookup("service:site-settings"); - if (!siteSettings.ai_bot_enable_dedicated_ux) { - return; - } - - const currentUser = api.container.lookup("service:current-user"); - if (!currentUser) { - return; - } - - const aiConversationsSidebarManager = api.container.lookup( - "service:ai-conversations-sidebar-manager" - ); - aiConversationsSidebarManager.api = api; - - api.addSidebarPanel( - (BaseCustomSidebarPanel) => - class AiConversationsSidebarPanel extends BaseCustomSidebarPanel { - key = AI_CONVERSATIONS_PANEL; - hidden = true; - displayHeader = false; // this would add a misplaced back to forum button - expandActiveSection = true; - } - ); - - api.renderInOutlet( - "before-sidebar-sections", - AiBotSidebarNewConversation - ); - - const setSidebarPanel = (transition) => { - if (transition?.to?.name === "discourse-ai-bot-conversations") { - return aiConversationsSidebarManager.forceCustomSidebar(); - } - - const topic = api.container.lookup("controller:topic").model; - // if the topic is not a private message, not created by the current user, - // or doesn't have a bot response, we don't need to override sidebar - if ( - topic?.archetype === "private_message" && - topic.user_id === currentUser.id && - topic.is_bot_pm - ) { - return aiConversationsSidebarManager.forceCustomSidebar(); - } - - aiConversationsSidebarManager.stopForcingCustomSidebar(); - }; - - api.container - .lookup("service:router") - .on("routeDidChange", setSidebarPanel); - }); - }, -}; diff --git a/assets/javascripts/initializers/ai-gist-topic-list-class.js b/assets/javascripts/initializers/ai-gist-topic-list-class.js deleted file mode 100644 index 543a89f5..00000000 --- a/assets/javascripts/initializers/ai-gist-topic-list-class.js +++ /dev/null @@ -1,19 +0,0 @@ -import { apiInitializer } from "discourse/lib/api"; - -export default apiInitializer("1.15.0", (api) => { - const gistService = api.container.lookup("service:gists"); - - api.registerValueTransformer( - "topic-list-item-class", - ({ value, context }) => { - const shouldShow = - gistService.preference === "table-ai" && gistService.shouldShow; - - if (context.topic.get("ai_topic_gist") && shouldShow) { - value.push("excerpt-expanded"); - } - - return value; - } - ); -}); diff --git a/assets/javascripts/initializers/ai-helper.js b/assets/javascripts/initializers/ai-helper.js deleted file mode 100644 index 1f8ee067..00000000 --- a/assets/javascripts/initializers/ai-helper.js +++ /dev/null @@ -1,90 +0,0 @@ -import { withPluginApi } from "discourse/lib/plugin-api"; -import { i18n } from "discourse-i18n"; -import AiComposerHelperMenu from "../discourse/components/ai-composer-helper-menu"; -import ModalDiffModal from "../discourse/components/modal/diff-modal"; -import { showComposerAiHelper } from "../discourse/lib/show-ai-helper"; - -function initializeAiHelperTrigger(api) { - api.onToolbarCreate((toolbar) => { - const currentUser = api.getCurrentUser(); - const modal = api.container.lookup("service:modal"); - - const selectedText = (toolbarEvent) => { - const composerContent = toolbarEvent.getText(); - const selected = toolbarEvent.selected.value; - - if (selected && selected.length > 0) { - return selected; - } - - if (composerContent && composerContent.length > 0) { - return composerContent; - } - }; - toolbar.addButton({ - id: "ai-helper-trigger", - group: "extras", - icon: "discourse-sparkles", - title: "discourse_ai.ai_helper.context_menu.trigger", - preventFocus: true, - hideShortcutInTitle: true, - shortcut: "ALT+P", - shortcutAction: (toolbarEvent) => { - if (toolbarEvent.getText().length === 0) { - const toasts = api.container.lookup("service:toasts"); - - return toasts.error({ - class: "ai-proofread-error-toast", - duration: "short", - data: { - message: i18n("discourse_ai.ai_helper.no_content_error"), - }, - }); - } - - const mode = currentUser?.ai_helper_prompts.find( - (p) => p.name === "proofread" - ).name; - - modal.show(ModalDiffModal, { - model: { - mode, - selectedText: selectedText(toolbarEvent), - toolbarEvent, - showResultAsDiff: true, - }, - }); - }, - condition: () => - showComposerAiHelper( - api.container.lookup("service:composer").model, - api.container.lookup("service:site-settings"), - currentUser, - "context_menu" - ), - sendAction: (event) => { - const menu = api.container.lookup("service:menu"); - menu.show(document.querySelector(".ai-helper-trigger"), { - identifier: "ai-composer-helper-menu", - component: AiComposerHelperMenu, - modalForMobile: true, - interactive: true, - data: { - toolbarEvent: event, - selectedText: selectedText(event), - }, - }); - }, - }); - }); -} - -export default { - name: "discourse-ai-helper", - - initialize() { - withPluginApi("1.1.0", (api) => { - initializeAiHelperTrigger(api); - }); - }, -}; diff --git a/assets/javascripts/initializers/ai-image-caption.js b/assets/javascripts/initializers/ai-image-caption.js deleted file mode 100644 index 9d31a863..00000000 --- a/assets/javascripts/initializers/ai-image-caption.js +++ /dev/null @@ -1,249 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import { extractError, popupAjaxError } from "discourse/lib/ajax-error"; -import { apiInitializer } from "discourse/lib/api"; -import { - getUploadMarkdown, - IMAGE_MARKDOWN_REGEX, - isImage, -} from "discourse/lib/uploads"; -import { i18n } from "discourse-i18n"; - -export default apiInitializer("1.25.0", (api) => { - const buttonAttrs = { - label: i18n("discourse_ai.ai_helper.image_caption.button_label"), - icon: "discourse-sparkles", - class: "generate-caption", - }; - const settings = api.container.lookup("service:site-settings"); - const currentUser = api.getCurrentUser(); - - if ( - !settings.ai_helper_enabled_features.includes("image_caption") || - !currentUser?.can_use_assistant - ) { - return; - } - - api.addSaveableUserOptionField("auto_image_caption"); - - api.addComposerImageWrapperButton( - buttonAttrs.label, - buttonAttrs.class, - buttonAttrs.icon, - (event) => { - const imageCaptionPopup = api.container.lookup( - "service:imageCaptionPopup" - ); - - imageCaptionPopup.popupTrigger = event.target; - - if ( - imageCaptionPopup.popupTrigger.classList.contains("generate-caption") - ) { - const buttonWrapper = event.target.closest(".button-wrapper"); - const imageIndex = parseInt( - buttonWrapper.getAttribute("data-image-index"), - 10 - ); - const imageSrc = event.target - .closest(".image-wrapper") - .querySelector("img") - .getAttribute("src"); - - imageCaptionPopup.toggleLoadingState(true); - - const site = api.container.lookup("service:site"); - if (!site.mobileView) { - imageCaptionPopup.showPopup = !imageCaptionPopup.showPopup; - } - - imageCaptionPopup._request = ajax( - `/discourse-ai/ai-helper/caption_image`, - { - method: "POST", - data: { - image_url: imageSrc, - image_url_type: "long_url", - }, - } - ); - - imageCaptionPopup._request - .then(({ caption }) => { - imageCaptionPopup.imageSrc = imageSrc; - imageCaptionPopup.imageIndex = imageIndex; - imageCaptionPopup.newCaption = caption; - - if (site.mobileView) { - // Auto-saves caption on mobile view - imageCaptionPopup.updateCaption(); - } - }) - .catch(popupAjaxError) - .finally(() => { - imageCaptionPopup.toggleLoadingState(false); - }); - } - } - ); - - // Checks if image is small (≤ 0.1 MP) - function isSmallImage(width, height) { - const megapixels = (width * height) / 1000000; - return megapixels <= 0.1; - } - - function needsImprovedCaption(caption) { - return caption.length < 20 || caption.split(" ").length === 1; - } - - function getUploadUrlFromMarkdown(markdown) { - const regex = /\(upload:\/\/([^)]+)\)/; - const match = markdown.match(regex); - return match ? `upload://${match[1]}` : null; - } - - async function fetchImageCaption(imageUrl, urlType) { - try { - const response = await ajax(`/discourse-ai/ai-helper/caption_image`, { - method: "POST", - data: { - image_url: imageUrl, - image_url_type: urlType, - }, - }); - return response.caption; - } catch (error) { - toasts.error({ - class: "ai-image-caption-error-toast", - duration: "short", - data: { - message: extractError(error), - }, - }); - } - } - - const autoCaptionAllowedGroups = - settings?.ai_auto_image_caption_allowed_groups - .split("|") - .map((id) => parseInt(id, 10)); - const currentUserGroups = currentUser.groups.map((g) => g.id); - - if ( - !currentUserGroups.some((groupId) => - autoCaptionAllowedGroups.includes(groupId) - ) - ) { - return; - } - - const toasts = api.container.lookup("service:toasts"); - // Automatically caption uploaded images - api.addComposerUploadMarkdownResolver(async (upload) => { - const autoCaptionEnabled = currentUser.get( - "user_option.auto_image_caption" - ); - - if ( - !autoCaptionEnabled || - !isImage(upload.url) || - !needsImprovedCaption(upload.original_filename) || - isSmallImage(upload.width, upload.height) - ) { - return getUploadMarkdown(upload); - } - - const caption = await fetchImageCaption(upload.url, "long_url"); - if (!caption) { - return getUploadMarkdown(upload); - } - return `![${caption}|${upload.thumbnail_width}x${upload.thumbnail_height}](${upload.short_url})`; - }); - - // Conditionally show dialog to auto image caption - api.composerBeforeSave(() => { - return new Promise((resolve, reject) => { - const dialog = api.container.lookup("service:dialog"); - const composer = api.container.lookup("service:composer"); - const localePrefix = - "discourse_ai.ai_helper.image_caption.automatic_caption_dialog"; - const autoCaptionEnabled = currentUser.get( - "user_option.auto_image_caption" - ); - - const imageUploads = composer.model.reply.match(IMAGE_MARKDOWN_REGEX); - const hasImageUploads = imageUploads?.length > 0; - - if (!hasImageUploads) { - resolve(); - } - - const imagesToCaption = imageUploads.filter((image) => { - const caption = image - .substring(image.indexOf("[") + 1, image.indexOf("]")) - .split("|")[0]; - // We don't check if the image is small to show the prompt here - // because the width/height are the thumbnail sizes so the mp count - // is incorrect. It doesn't matter because the auto caption won't - // happen anyways if its small because that uses the actual upload dimensions - return needsImprovedCaption(caption); - }); - - const needsBetterCaptions = imagesToCaption?.length > 0; - - const keyValueStore = api.container.lookup("service:key-value-store"); - const imageCaptionPopup = api.container.lookup( - "service:imageCaptionPopup" - ); - const autoCaptionPromptKey = "ai-auto-caption-seen"; - const seenAutoCaptionPrompt = keyValueStore.getItem(autoCaptionPromptKey); - - if (autoCaptionEnabled || !needsBetterCaptions || seenAutoCaptionPrompt) { - return resolve(); - } - - keyValueStore.setItem(autoCaptionPromptKey, true); - - dialog.confirm({ - message: i18n(`${localePrefix}.prompt`), - confirmButtonLabel: `${localePrefix}.confirm`, - cancelButtonLabel: `${localePrefix}.cancel`, - class: "ai-image-caption-prompt-dialog", - - didConfirm: async () => { - try { - currentUser.set("user_option.auto_image_caption", true); - await currentUser.save(["auto_image_caption"]); - - imagesToCaption.forEach(async (imageMarkdown) => { - const uploadUrl = getUploadUrlFromMarkdown(imageMarkdown); - imageCaptionPopup.showAutoCaptionLoader = true; - const caption = await fetchImageCaption(uploadUrl, "short_url"); - - // Find and replace the caption in the reply - const regex = new RegExp( - `(!\\[)[^|]+(\\|[^\\]]+\\]\\(${uploadUrl}\\))` - ); - const newReply = composer.model.reply.replace( - regex, - `$1${caption}$2` - ); - composer.model.set("reply", newReply); - imageCaptionPopup.showAutoCaptionLoader = false; - resolve(); - }); - } catch (error) { - // Reject the promise if an error occurs - // Show an error saying unable to generate captions - reject(error); - } - }, - didCancel: () => { - // Don't enable auto captions and continue with the save - resolve(); - }, - }); - }); - }); -}); diff --git a/assets/javascripts/initializers/ai-search-discoveries.js b/assets/javascripts/initializers/ai-search-discoveries.js deleted file mode 100644 index 339cb944..00000000 --- a/assets/javascripts/initializers/ai-search-discoveries.js +++ /dev/null @@ -1,15 +0,0 @@ -import { apiInitializer } from "discourse/lib/api"; - -export default apiInitializer((api) => { - const currentUser = api.getCurrentUser(); - const settings = api.container.lookup("service:site-settings"); - - if ( - !settings.ai_bot_enabled || - !currentUser?.can_use_ai_bot_discover_persona - ) { - return; - } - - api.addSaveableUserOptionField("ai_search_discoveries"); -}); diff --git a/assets/javascripts/initializers/ai-semantic-search.js b/assets/javascripts/initializers/ai-semantic-search.js deleted file mode 100644 index e145ad88..00000000 --- a/assets/javascripts/initializers/ai-semantic-search.js +++ /dev/null @@ -1,9 +0,0 @@ -import { apiInitializer } from "discourse/lib/api"; - -export default apiInitializer("1.15.0", (api) => { - api.modifyClass("component:search-result-entry", { - pluginId: "discourse-ai", - - classNameBindings: ["bulkSelectEnabled", "post.generatedByAi:ai-result"], - }); -}); diff --git a/assets/javascripts/initializers/ai-sentiment-admin-nav.js b/assets/javascripts/initializers/ai-sentiment-admin-nav.js deleted file mode 100644 index 9bf45633..00000000 --- a/assets/javascripts/initializers/ai-sentiment-admin-nav.js +++ /dev/null @@ -1,27 +0,0 @@ -import { apiInitializer } from "discourse/lib/api"; - -export default apiInitializer("1.15.0", (api) => { - const currentUser = api.getCurrentUser(); - - if ( - !currentUser || - !currentUser.admin || - !currentUser.can_see_sentiment_reports - ) { - return; - } - - api.addAdminSidebarSectionLink("reports", { - name: "sentiment_overview", - route: "admin.dashboardSentiment", - label: "discourse_ai.sentiments.sidebar.overview", - icon: "chart-column", - }); - api.addAdminSidebarSectionLink("reports", { - name: "sentiment_analysis", - route: "adminReports.show", - routeModels: ["sentiment_analysis"], - label: "discourse_ai.sentiments.sidebar.analysis", - icon: "chart-pie", - }); -}); diff --git a/assets/javascripts/initializers/related-topics.gjs b/assets/javascripts/initializers/related-topics.gjs deleted file mode 100644 index 3442af9b..00000000 --- a/assets/javascripts/initializers/related-topics.gjs +++ /dev/null @@ -1,78 +0,0 @@ -import { cached, tracked } from "@glimmer/tracking"; -import BasicTopicList from "discourse/components/basic-topic-list"; -import icon from "discourse/helpers/d-icon"; -import { withPluginApi } from "discourse/lib/plugin-api"; -import { i18n } from "discourse-i18n"; - -const RelatedTopics = ; - -export default { - name: "discourse-ai-related-topics", - - initialize(container) { - const settings = container.lookup("service:site-settings"); - - if ( - !settings.ai_embeddings_enabled || - !settings.ai_embeddings_semantic_related_topics_enabled - ) { - return; - } - - withPluginApi("1.37.2", (api) => { - api.registerMoreTopicsTab({ - id: "related-topics", - name: i18n("discourse_ai.related_topics.pill"), - icon: "discourse-sparkles", - component: RelatedTopics, - condition: ({ topic }) => topic.relatedTopics?.length, - }); - - api.modifyClass( - "model:topic", - (Superclass) => - class extends Superclass { - @tracked related_topics; - relatedTopicsCache = []; - - @cached - get relatedTopics() { - // Used to keep related topics when a user scrolls up from the - // bottom of the topic and then scrolls back down - if (this.related_topics) { - this.relatedTopicsCache = this.related_topics; - } - return this.relatedTopicsCache?.map((topic) => - this.store.createRecord("topic", topic) - ); - } - } - ); - - api.modifyClass( - "model:post-stream", - (Superclass) => - class extends Superclass { - _setSuggestedTopics(result) { - super._setSuggestedTopics(...arguments); - this.topic.related_topics = result.related_topics; - } - } - ); - }); - }, -}; diff --git a/assets/javascripts/initializers/translation.js b/assets/javascripts/initializers/translation.js deleted file mode 100644 index d327ff68..00000000 --- a/assets/javascripts/initializers/translation.js +++ /dev/null @@ -1,24 +0,0 @@ -import { apiInitializer } from "discourse/lib/api"; -import cookie from "discourse/lib/cookie"; - -export default apiInitializer((api) => { - const settings = api.container.lookup("service:site-settings"); - - if (!settings.discourse_ai_enabled || !settings.ai_translation_enabled) { - return; - } - - api.registerCustomPostMessageCallback( - "localized", - (topicController, data) => { - if (!cookie("content-localization-show-original")) { - const postStream = topicController.get("model.postStream"); - postStream.triggerChangedPost(data.id, data.updated_at).then(() => { - topicController.appEvents.trigger("post-stream:refresh", { - id: data.id, - }); - }); - } - } - ); -}); diff --git a/assets/javascripts/lib/discourse-markdown/ai-tags.js b/assets/javascripts/lib/discourse-markdown/ai-tags.js deleted file mode 100644 index 492e5540..00000000 --- a/assets/javascripts/lib/discourse-markdown/ai-tags.js +++ /dev/null @@ -1,11 +0,0 @@ -export function setup(helper) { - helper.allowList(["details[class=ai-quote]"]); - helper.allowList([ - "div[class=ai-artifact]", - "div[data-ai-artifact-id]", - "div[data-ai-artifact-version]", - "div[data-ai-artifact-autorun]", - "div[data-ai-artifact-height]", - "div[data-ai-artifact-width]", - ]); -} diff --git a/assets/stylesheets/common/ai-blinking-animation.scss b/assets/stylesheets/common/ai-blinking-animation.scss deleted file mode 100644 index b43ce99e..00000000 --- a/assets/stylesheets/common/ai-blinking-animation.scss +++ /dev/null @@ -1,139 +0,0 @@ -.ai-blinking-animation { - list-style: none; - display: flex; - flex-wrap: wrap; - padding: 0; - margin: 0; - - &__list-item { - background: var(--primary-300); - border-radius: var(--d-border-radius); - margin-right: 8px; - margin-bottom: 8px; - height: 1em; - opacity: 0; - display: block; - - &:nth-child(1) { - width: 10%; - } - - &:nth-child(2) { - width: 12%; - } - - &:nth-child(3) { - width: 18%; - } - - &:nth-child(4) { - width: 14%; - } - - &:nth-child(5) { - width: 18%; - } - - &:nth-child(6) { - width: 14%; - } - - &:nth-child(7) { - width: 22%; - } - - &:nth-child(8) { - width: 5%; - } - - &:nth-child(9) { - width: 25%; - } - - &:nth-child(10) { - width: 14%; - } - - &:nth-child(11) { - width: 18%; - } - - &:nth-child(12) { - width: 12%; - } - - &:nth-child(13) { - width: 22%; - } - - &:nth-child(14) { - width: 18%; - } - - &:nth-child(15) { - width: 13%; - } - - &:nth-child(16) { - width: 22%; - } - - &:nth-child(17) { - width: 19%; - } - - &:nth-child(18) { - width: 13%; - } - - &:nth-child(19) { - width: 22%; - } - - &:nth-child(20) { - width: 25%; - } - - &.is-shown { - opacity: 1; - } - - &.show { - animation: appear 0.5s cubic-bezier(0.445, 0.05, 0.55, 0.95) 0s forwards; - - @media (prefers-reduced-motion) { - animation-duration: 0s; - } - } - - @media (prefers-reduced-motion: no-preference) { - &.blink { - animation: blink 0.5s cubic-bezier(0.55, 0.085, 0.68, 0.53) both; - } - } - } - - @keyframes appear { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } - } - - @keyframes blink { - 0% { - opacity: 1; - } - - 50% { - opacity: 0.5; - } - - 100% { - opacity: 1; - } - } -} diff --git a/assets/stylesheets/common/ai-features.scss b/assets/stylesheets/common/ai-features.scss deleted file mode 100644 index a97243a7..00000000 --- a/assets/stylesheets/common/ai-features.scss +++ /dev/null @@ -1,160 +0,0 @@ -.ai-features-list { - margin-block: 2rem; - display: flex; - flex-direction: column; - gap: 3rem; -} - -.ai-module { - &__header { - border-bottom: 1px solid var(--primary-low); - padding-bottom: var(--space-2); - } - - &__module-title { - display: flex; - justify-content: space-between; - } -} - -.ai-feature-cards { - gap: var(--space-4); -} - -.ai-feature-card { - background: var(--secondary); - border: 1px solid var(--primary-low); - border-radius: var(--d-border-radius); - padding: var(--space-3) var(--space-4) var(--space-2); - display: flex; - flex-direction: column; - - &.admin-section-landing-item { - margin: 0; - } - - &__feature-name { - margin-bottom: var(--space-2); - } - - &__label { - margin: var(--space-1) var(--space-1) 0 0; - } - - &__llm, - &__persona, - &__groups { - font-size: var(--font-down-1-rem); - display: flex; - align-items: baseline; - flex-flow: row wrap; - color: var(--primary-high); - } - - &__persona { - @include ellipsis; - } - - &__persona-button, - &__llm-button { - padding: 0; - margin-right: var(--space-1); - overflow: hidden; - - .d-button-label { - min-height: 1.5em; - - @include ellipsis; - } - } - - &__groups { - display: flex; - flex-flow: row wrap; - gap: var(--space-1); - } - - &__item-groups { - list-style: none; - display: flex; - flex-flow: row wrap; - gap: var(--space-1); - margin: 0; - - li { - font-size: var(--font-down-1); - border-radius: var(--d-border-radius); - background: var(--primary-very-low); - border: 1px solid var(--primary-low); - padding: 1px 3px; - } - } -} - -.ai-feature-editor { - &__header { - border-bottom: 1px solid var(--primary-low); - } - - .setting { - margin-block: 1.5rem; - } - - .setting-label { - font-size: var(--font-down-1-rem); - color: var(--primary-high); - - a[title="View change history"], - .history-icon { - display: none; - } - } - - .setting-value { - .desc { - font-size: var(--font-down-1-rem); - color: var(--primary-high-or-secondary-low); - } - } - - .setting-controls, - .setting-controls__undo { - font-size: var(--font-down-1-rem); - margin-top: var(--space-2); - } -} - -.ai-features__controls { - display: flex; - gap: var(--space-2); - - .filter-input-container { - flex: 6 1 auto; - } - - .d-select { - flex: 1 1 auto; - max-width: 10em; - } -} - -.ai-features__no-results { - display: flex; - flex-direction: column; - text-align: center; - justify-content: center; - padding: var(--space-6); - gap: var(--space-2); - - h3 { - font-weight: normal; - } - - .btn { - align-self: center; - } -} - -.ai-expanded-list__toggle-button { - padding: 0; -} diff --git a/assets/stylesheets/common/ai-user-settings.scss b/assets/stylesheets/common/ai-user-settings.scss deleted file mode 100644 index 95fd6a1e..00000000 --- a/assets/stylesheets/common/ai-user-settings.scss +++ /dev/null @@ -1,13 +0,0 @@ -.user-preferences .ai-user-preferences { - legend { - margin-bottom: 1rem; - } - - .control-group { - margin-bottom: 0; - } - - .save-button { - margin-top: 2rem; - } -} diff --git a/assets/stylesheets/common/streaming.scss b/assets/stylesheets/common/streaming.scss deleted file mode 100644 index bbeb720f..00000000 --- a/assets/stylesheets/common/streaming.scss +++ /dev/null @@ -1,130 +0,0 @@ -@keyframes flashing { - 0%, - 100% { - opacity: 0; - } - - 50% { - opacity: 1; - } -} - -@mixin progress-dot { - content: "\25CF"; - font-family: - "Söhne Circle", - system-ui, - -apple-system, - "Segoe UI", - Roboto, - Ubuntu, - Cantarell, - "Noto Sans", - sans-serif; - line-height: normal; - margin-left: 0.25rem; - vertical-align: baseline; - animation: flashing 1.5s 3s infinite; - display: inline-block; - font-size: 1rem; - color: var(--tertiary-medium); -} - -.streamable-content.streaming .cooked p:last-child::after { - @include progress-dot; -} - -article.streaming .cooked { - .progress-dot::after { - @include progress-dot; - } - - > .progress-dot:only-child::after { - // if the progress dot is the only content - // we are likely waiting longer for a response - // so it can start animating instantly - animation: flashing 1.5s infinite; - } -} - -@keyframes ai-indicator-wave { - 0%, - 60%, - 100% { - transform: initial; - } - - 30% { - transform: translateY(-0.2em); - } -} - -.ai-indicator-wave { - flex: 0 0 auto; - display: inline-flex; - - &__dot { - display: inline-block; - - @media (prefers-reduced-motion: no-preference) { - animation: ai-indicator-wave 1.8s linear infinite; - } - - &:nth-child(2) { - animation-delay: -1.6s; - } - - &:nth-child(3) { - animation-delay: -1.4s; - } - } -} - -@keyframes mark-blink { - 0%, - 100% { - border-color: transparent; - } - - 50% { - border-color: var(--highlight-high); - } -} - -@keyframes fade-in-highlight { - from { - opacity: 0.5; - } - - to { - opacity: 1; - } -} - -mark.highlight { - background-color: var(--highlight-high); - animation: fade-in-highlight 0.5s ease-in-out forwards; -} - -.composer-ai-helper-modal__suggestion.thinking mark.highlight { - animation: mark-blink 1s step-start 0s infinite; - animation-name: mark-blink; -} - -.composer-ai-helper-modal__loading.inline-diff { - white-space: pre-wrap; -} - -.composer-ai-helper-modal__suggestion.inline-diff { - white-space: pre-wrap; - - del:last-child { - text-decoration: none; - background-color: transparent; - color: var(--primary-low-mid); - } - - .diff-inner { - display: inline; - } -} diff --git a/assets/stylesheets/modules/ai-bot-conversations/common.scss b/assets/stylesheets/modules/ai-bot-conversations/common.scss deleted file mode 100644 index 27275aa4..00000000 --- a/assets/stylesheets/modules/ai-bot-conversations/common.scss +++ /dev/null @@ -1,517 +0,0 @@ -@use "lib/viewport"; - -// Hide the new question button from the hamburger menu's footer on desktop -.desktop-view .hamburger-panel .ai-new-question-button { - display: none; -} - -body.has-ai-conversations-sidebar { - .ai-new-question-button { - width: 100%; - - &__wrapper { - background: var(--secondary); - margin: 1.8em 1em 0; - - .mobile-view & { - padding: 1em; - position: sticky; - top: 0; - margin: -0.5em 0 0; // avoid shift when sticking - z-index: 1; - } - } - } - - // When the sidebar is empty, we want to hide the "Today" header - // and only show the empty state component - &.has-empty-ai-conversations-sidebar - .sidebar-section[data-section-name="today"] - .sidebar-section-header-wrapper { - display: none; - } - - // When the sidebar isn't empty, BUT "today" is empty, hide - // the entire section - &:not(.has-empty-ai-conversations-sidebar) - .sidebar-section[data-section-name="today"]:has( - .ai-bot-sidebar-empty-state - ) { - display: none; - } - - .sidebar-toggle-all-sections { - display: none; - } - - // topic elements - #topic-footer-button-share-and-invite, - body:not(.staff) #topic-footer-button-archive, - #topic-footer-buttons .topic-notifications-button, - .bookmark-menu-trigger, - .more-topics__container, - .private-message-glyph-wrapper, - .topic-header-participants, - .topic-above-footer-buttons-outlet, - #topic-footer-buttons .topic-footer-main-buttons details { - display: none; - } - - .topic-timer-info { - border: none; - } - - .topic-owner .actions .create-flag { - // why flag my own post - display: none; - } - - .container.posts { - margin-bottom: 0; - - .topic-navigation.with-timeline { - top: calc(var(--header-offset, 60px) + 5.5em); - } - - .topic-navigation { - .topic-notifications-button { - display: none; - } - } - } - - #topic-title { - display: flex; - justify-content: center; - width: 100%; - - .title-wrapper { - width: 100%; - max-width: 960px; - } - } - - .small-action, - .onscreen-post .row { - justify-content: center; - } - - #topic-footer-buttons { - margin-top: 1em; - width: 100%; - max-width: 50.5em; - - .topic-footer-main-buttons { - justify-content: flex-end; - } - } - - #topic-progress-wrapper.docked { - display: none; - } - - @include viewport.until(lg) { - .archetype-private_message .topic-post:last-child { - margin-bottom: 0; - } - } - - nav.post-controls .actions button { - padding: 0.5em 0.65em; - - &.reply { - .d-icon { - margin-right: 0.45em; - } - } - } - - .ai-bot-conversations { - --input-max-width: 46em; - display: flex; - flex-direction: column; - height: calc(100dvh - var(--header-offset) - 5em); - - .persona-llm-selector { - display: flex; - gap: 0.5em; - justify-content: flex-start; - - .select-kit-body { - .select-kit-collection { - max-height: 50vh; - overflow-y: auto; - } - } - - &__selection-wrapper { - display: flex; - flex-direction: column; - min-width: 0; - - @include viewport.until(sm) { - .select-kit-header-wrapper { - font-size: var(--font-down-1); - } - } - - label { - font-size: var(--font-down-1); - font-weight: 300; - margin-left: 1em; - margin-bottom: 0; - } - - .name { - display: block; - - @include ellipsis; - } - } - - .btn { - display: flex; - justify-content: flex-start; - background-color: transparent; - font-weight: bold; - } - - .btn:hover, - .btn:focus { - background-color: transparent; - color: var(--primary); - } - - .btn:hover .d-icon, - .btn:focus .d-icon { - color: var(--primary); - } - } - - &__content-wrapper { - display: flex; - flex-direction: column; - box-sizing: border-box; - align-items: center; - justify-content: center; - flex: 1 1 auto; - gap: 0.5em; - - .loading-container { - display: contents; - } - } - - &__title { - font-size: var(--font-up-5); - font-weight: bold; - text-align: center; - margin-bottom: 0.25em; - line-height: var(--line-height-medium); - - // optical centering for layout balance - @media screen and (min-height: 600px) { - margin-top: -6em; - } - } - - &__input-wrapper { - --input-min-height: 2.5em; - display: flex; - align-items: end; - width: 100%; - border: 1px solid var(--primary-low); - border-radius: var(--d-input-border-radius); - - &:has(textarea[disabled]) { - background: var(--primary-very-low); - } - - @include viewport.from(sm) { - width: 80%; - max-width: var(--input-max-width); - } - - &:focus-within { - border-color: var(--tertiary); - } - - .ai-conversation-submit { - .d-icon { - color: var(--primary-medium); - padding: 0.5em; - } - - &:hover, - &:focus-visible { - .d-icon { - color: var(--primary-medium); - } - } - } - - .ai-bot-upload-btn { - min-height: var(--input-min-height); - border: none; - - .d-icon { - background: var(--primary-low); - padding: 0.5em; - border-radius: 100%; - } - - &:hover, - &:focus-visible { - .d-icon { - color: var(--primary); - } - } - } - - #ai-bot-conversations-input { - --scrollbarBg: transparent; - --scrollbarThumbBg: var(--primary-low); - --scrollbarWidth: 10px; - box-sizing: border-box; - flex-grow: 1; - margin: 0; - resize: none; - max-height: 30vh; - min-height: var(--input-min-height); - border-radius: 0 var(--d-button-border-radius) - var(--d-button-border-radius) 0; - border: none; - padding-block: 0.8em; - padding-inline: 0; - scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg); - scrollbar-width: thin; - transition: scrollbar-color 0.25s ease-in-out; - height: 100%; - line-height: var(--line-height-large); - - &::-webkit-scrollbar-thumb { - background-color: var(--scrollbarThumbBg); - border-radius: calc(var(--scrollbarWidth) / 2); - border: calc(var(--scrollbarWidth) / 4) solid var(--secondary); - } - - &::-webkit-scrollbar-track { - background-color: transparent; - } - - &::-webkit-scrollbar { - width: var(--scrollbarWidth); - } - - &::-moz-scrollbar-thumb { - background-color: var(--scrollbarThumbBg); - border-radius: calc(var(--scrollbarWidth) / 2); - border: calc(var(--scrollbarWidth) / 4) solid var(--secondary); - } - - &::-moz-scrollbar-track { - background-color: transparent; - } - - &::-moz-scrollbar { - width: var(--scrollbarWidth); - } - - &:focus-visible { - outline: none; - border-color: var(--tertiary); - } - - &:not(:placeholder-shown) + .ai-conversation-submit { - will-change: scale; - - &:hover, - &:focus-visible { - transform: scale(1.2); - } - - .d-icon { - color: var(--tertiary); - } - } - } - } - - .ai-disclaimer { - text-align: center; - font-size: var(--font-down-1); - color: var(--primary-700); - margin: 0; - - @include viewport.from(sm) { - width: 80%; - max-width: var(--input-max-width); - } - } - - .sidebar-footer-wrapper { - display: flex; - - .powered-by-discourse { - display: block; - } - - button { - display: none; - } - } - - .topic-footer-main-buttons { - justify-content: flex-end; - } - - .ai-bot-conversations__uploads-container { - width: 100%; - display: flex; - flex-wrap: wrap; - gap: 0.5em; - - @include viewport.from(sm) { - width: 80%; - max-width: var(--input-max-width); - } - } - - .ai-bot-upload { - display: flex; - align-items: center; - border: 1px solid var(--primary-low); - border-radius: 10em; - padding-left: 0.75em; - color: var(--primary-high); - font-size: var(--font-down-2); - - &__progress { - margin-left: 0.5em; - } - - &:hover, - &:focus-visible { - .d-icon { - color: var(--danger); - } - } - } - } - - @include viewport.until(sm) { - .share-ai-conversation-button { - .d-icon { - margin: 0; - } - - .d-button-label { - display: none; - } - } - } - - // custom user card link - .user-card-meta__profile-link { - display: block; - padding: 0.5em 0 0.25em; - - .d-icon { - font-size: var(--font-down-1); - margin-right: 0.15em; - } - } - - // hide extra buttons - .timeline-container .topic-timeline .timeline-footer-controls { - display: none; - } - - .topic-footer-main-buttons { - button:not( - .create, - .share-ai-conversation-button, - .topic-admin-menu-trigger - ) { - display: none; - } - } - - .topic-map { - box-sizing: border-box; - width: 100%; - margin: 0 auto; - padding-block: 0.5em; - - .topic-map__views-trigger, - .topic-map__likes-trigger, - .summarization-button, - &__private-message-map, - .topic-map__users-list { - display: none; - } - - &.--bottom { - padding-top: 0; - } - - section { - background: transparent; - border-block: 1px solid var(--primary-low); - } - - &__contents { - padding: 0.5em 1.25em; - - @include viewport.from(sm) { - padding: 0.5em 0.5em 0.5em 1.9em; - } - } - - &__stats { - height: 100%; - flex-wrap: nowrap; - gap: 1em; - } - - .ai-conversation__participants { - display: flex; - flex-wrap: wrap; - gap: 0.5em 0.25em; - align-items: center; - - .avatar { - width: 2em; - height: 2em; - } - - .trigger-group-card { - display: flex; - align-items: center; - border: 1px solid var(--primary-low); - border-radius: var(--d-button-border-radius); - padding: 0.25em 0.5em; - font-size: var(--font-down-1); - - span { - position: relative; - top: -1px; - } - - .d-icon { - position: relative; - top: 1px; - } - - a { - color: var(--primary-medium); - } - } - - .btn { - font-size: var(--font-down-1); - margin-right: 0.5em; - } - } - } -} diff --git a/assets/stylesheets/modules/ai-bot/common/ai-artifact.scss b/assets/stylesheets/modules/ai-bot/common/ai-artifact.scss deleted file mode 100644 index 8867c45f..00000000 --- a/assets/stylesheets/modules/ai-bot/common/ai-artifact.scss +++ /dev/null @@ -1,151 +0,0 @@ -@keyframes artifact-remove { - to { - height: 0; - overflow: hidden; - } -} - -@keyframes artifact-fade { - to { - opacity: 0; - } -} - -.ai-artifact__wrapper { - height: 500px; - padding-bottom: 2em; - - iframe { - width: 100%; - height: calc(100% - 2em); - } - - &.ai-artifact__seamless { - padding-bottom: 1em; - - iframe { - height: 100%; - } - } - - &:not(.ai-artifact__expanded, .ai-artifact__seamless) { - iframe { - box-shadow: var(--shadow-card); - } - } -} - -.ai-artifact__click-to-run { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - background: var(--primary-very-low); -} - -.ai-artifact__panel { - display: none; -} - -html.ai-artifact-expanded { - overflow: hidden; -} - -.ai-artifact__footer { - display: flex; - justify-content: space-between; - align-items: center; - - .ai-artifact__expand-button { - margin-left: auto; - padding-right: 0; - - .d-icon { - font-size: var(--font-down-1); - } - } -} - -.ai-artifact__panel--wrapper { - opacity: 0; - transition: opacity 0.5s ease-in-out; -} - -.ai-artifact__expanded { - position: fixed; - top: 0; - left: 0; - height: 100%; - width: 100%; - z-index: z("fullscreen"); - background-color: var(--secondary); - - .ai-artifact__footer { - display: none; - } - - .ai-artifact__panel--wrapper { - position: absolute; - top: 2em; - left: 2em; - right: 2em; - height: 2em; - z-index: 1000000; - display: flex; - justify-content: center; - opacity: 1; - } - - .ai-artifact__panel { - display: block; - position: absolute; - animation: - artifact-fade 0.75s forwards, - artifact-remove 1s forwards; - animation-delay: 4s; - background-color: var(--primary); - opacity: 0.9; - border-radius: var(--d-button-border-radius); - transform: translateY(0); - box-shadow: var(--shadow-card); - font-size: var(--font-up-1); - - &:hover { - animation-play-state: paused; - opacity: 1; - } - - button { - box-sizing: border-box; - justify-content: center; - color: var(--secondary-very-high); - margin: 0 auto; - - &:hover { - color: var(--secondary-very-high); - - .d-icon { - color: var(--secondary-high); - } - } - } - } - - iframe { - position: fixed; - top: 0; - height: 100%; - max-height: 100%; - left: 0; - right: 0; - bottom: 0; - z-index: z("fullscreen"); - } -} - -.ai-share-full-topic-modal__body { - .ai-artifact-controls { - display: flex; - justify-content: space-between; - } -} diff --git a/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss b/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss deleted file mode 100644 index 83b9cfed..00000000 --- a/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss +++ /dev/null @@ -1,285 +0,0 @@ -@use "lib/viewport"; - -@keyframes fade-in { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -.ai-search-discoveries { - &__regular-results-title { - margin-top: 0.5em; - margin-bottom: 0; - } - - &__completion { - margin: 0; - } - - &__discovery { - &.preview { - height: 3.5em; // roughly the loading skeleton height - overflow: hidden; - position: relative; - - &::after { - content: ""; - position: absolute; - display: block; - background: linear-gradient(rgba(255, 255, 255, 0), var(--secondary)); - height: 50%; - width: 100%; - bottom: 0; - opacity: 0; - animation: fade-in 0.5s ease-in forwards; - } - } - } - - &__discoveries-title, - &__regular-results-title { - padding-bottom: 0.5em; - border-bottom: 1px solid var(--primary-low); - font-size: var(--font-0); - - .d-icon { - color: var(--primary-high); - } - } - - &__discoveries-title { - display: flex; - justify-content: space-between; - } - - &__toggle { - padding-left: 0; - margin-bottom: 0.5em; - } - - .cooked p:first-child { - margin-top: 0; - } - - &__continue-conversation { - margin-block: 1rem; - } -} - -.ai-search-discoveries-tooltip { - &__content { - padding: 0.5rem; - } - - &__header { - font-weight: bold; - margin-bottom: 0.5em; - } - - &__actions { - display: flex; - justify-content: space-between; - gap: 1rem; - margin-top: 1rem; - - .btn { - padding: 0; - } - } - - .fk-d-tooltip__trigger { - vertical-align: middle; - } - - .d-icon { - color: var(--primary-medium); - } -} - -.full-page-discoveries { - padding: 0 1rem; - - @include viewport.until(md) { - padding: 0.25rem 1rem 0 1rem; - } -} - -.d-icon-discobot { - // appears a little undersized next to other icons - height: 1.15em; - width: 1.15em; -} - -.ai-discobot-discoveries { - padding-top: 0.5em; -} - -@include breakpoint("medium", min-width) { - .search-menu .menu-panel:has(.ai-search-discoveries__discoveries-title) { - width: 80vw; - max-width: 900px; - transition: width 0.5s; - - .results { - display: grid; - grid-template-columns: 58% 38%; - grid-template-rows: auto auto 1fr; - gap: 0 4%; - - * { - // covers all non-discovery content - grid-column-start: 1; - } - - .ai-discobot-discoveries { - // always in the second column, always spans all rows - grid-column-start: 2; - grid-row: 1 / -1; - box-sizing: border-box; - padding: 0 0.5em 0 2em; - margin: 0.75em 0 0 0; - border-left: 1px solid var(--primary-low); - - .cooked { - font-size: var(--font-down-1); - } - } - } - - .ai-search-discoveries { - font-size: var(--font-0); - color: var(--primary-high); - padding-right: 0.5em; - } - - .ai-search-discoveries__regular-results-title { - display: none; - } - - .ai-search-discoveries__toggle { - display: none; - } - - .ai-search-discoveries__discovery.preview { - height: 100%; - - &::after { - display: none; - } - } - } -} - -.search-page .ai-search-discoveries__discoveries-wrapper { - padding-bottom: 0.5rem; -} - -.ai-search-discoveries__discoveries-title.full-page-discoveries { - border: none; - padding-top: 1rem; -} - -@mixin discoveries-sidebar { - .full-page-discoveries { - padding: 1em 10%; - } - - &.search-page.has-discoveries { - .semantic-search__container { - background: transparent; - margin: 0; - } - - .semantic-search__container .semantic-search__results { - .semantic-search__searching { - margin-left: 0; - } - - .semantic-search__searching-text { - margin-left: 1.25em; - } - } - - .search-container .search-header { - padding: 1em 2em; - } - - .semantic-search__container .search-results, - .search-container .search-advanced .search-results, - .search-container .search-advanced .search-info { - padding: 1em 2em; - } - - .search-results .fps-result { - padding: 0; - margin-bottom: 2.5em; - } - - .search-advanced { - display: grid; - grid-template-columns: 70% 30%; - grid-auto-rows: auto; - - > * { - grid-column: 1; - align-self: start; - } - } - - .search-info { - grid-row: 1; - } - - .ai-search-discoveries__discoveries-title { - border: none; - margin-bottom: 0; - padding-bottom: 0; - } - - .ai-search-discoveries__discoveries-wrapper { - grid-column: 2 / -1; - grid-row: 1 / 5; - border-left: 1px solid var(--primary-low); - align-self: stretch; - - .cooked { - color: var(--primary-high); - } - } - } -} - -body:not(.has-sidebar-page) { - @include viewport.from(md) { - @include discoveries-sidebar; - } -} - -body.has-sidebar-page { - @include viewport.from(lg) { - @include discoveries-sidebar; - } - - @include viewport.between(md, lg) { - .ai-search-discoveries__discoveries-wrapper { - padding-bottom: 0; - } - - .ai-search-discoveries__discoveries-title { - padding-top: 1rem; - } - - .search-container .search-advanced .search-info, - .semantic-search__container.search-results { - padding-inline: 10%; - } - - .full-page-discoveries { - padding-inline: 10%; - } - } -} diff --git a/assets/stylesheets/modules/ai-bot/common/ai-persona.scss b/assets/stylesheets/modules/ai-bot/common/ai-persona.scss deleted file mode 100644 index 1b96fc1c..00000000 --- a/assets/stylesheets/modules/ai-bot/common/ai-persona.scss +++ /dev/null @@ -1,333 +0,0 @@ -@use "lib/viewport"; - -.admin-contents .ai-persona-list-editor { - margin-top: 0; -} - -.ai-persona-list-editor { - @include viewport.until(md) { - td { - border: none; - padding: 0; - - &.d-admin-row__llms, - &.d-admin-row__features { - padding-block: 0; - - .--card-label { - display: inline-block; - font-size: var(--font-down-1); - color: var(--primary-high); - } - } - } - } - - &__header { - display: flex; - justify-content: space-between; - align-items: center; - margin: 0 0 1em 0; - - h3 { - margin: 0; - } - } - - &__current { - padding-left: 20px; - } - - li.disabled { - opacity: 0.5; - } - - &__controls { - display: flex; - gap: var(--space-2); - margin-bottom: var(--space-4); - - .filter-input-container { - flex: 4 1 auto; - } - - .d-select { - flex: 1 1 auto; - width: auto; - height: auto; - } - } - - &__no-results { - display: flex; - flex-direction: column; - text-align: center; - justify-content: center; - padding: var(--space-6); - gap: var(--space-2); - - h3 { - font-weight: normal; - } - - .btn { - align-self: center; - } - } - - &.--layout-table { - .--card-label { - display: none; - } - } - - &.--layout-card { - tbody { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(18em, 1fr)); - gap: var(--space-4); - border: none; - } - - thead { - display: none; - } - - .d-admin-row__content { - display: grid; - grid-template-rows: auto 1fr auto auto; - grid-template-columns: 1fr auto; - border: 1px solid var(--primary-low); - padding: var(--space-2) var(--space-4) var(--space-4); - border-radius: var(--d-border-radius); - - .d-admin-row__overview, - .ai-persona-list__name-with-description { - display: contents; - } - - .ai-persona-list__name { - grid-row: 1; - grid-column: 1; - font-size: var(--font-up-1); - display: inline; - align-self: center; - - .avatar { - height: 1.25em; - position: relative; - bottom: 0.15em; - } - } - - .ai-persona-list__description { - grid-row: 2; - grid-column: 1; - margin: var(--space-2) 0; - } - - .d-admin-row__controls { - grid-row: 1; - grid-column: 2; - } - - .d-admin-row__features { - grid-row: 4; - grid-column: 1 / span 2; - padding: 0; - - .btn { - margin-top: var(--space-0); - } - } - - .d-admin-row__llms { - grid-row: 3; - grid-column: 1 / span 2; - padding: 0; - - .btn { - margin-top: var(--space-2); - } - } - - .--card-label { - color: var(--primary-high); - font-size: var(--font-down-1); - } - } - } - - .ai-persona-list { - &__row-item-feature { - padding: 0; - text-align: left; - } - - &__description { - color: var(--primary-high); - } - - &__name { - display: flex; - align-items: center; - gap: var(--space-1); - font-size: var(--font-up-0); - color: var(--primary); - margin: 0; - padding: 0; - line-height: var(--line-height-medium); - - .avatar { - width: auto; - height: 1.25em; - } - } - - &__row { - &:hover { - background: transparent; - } - - &__overview { - padding-left: var(--space-2); - } - } - } - - .d-admin-row__row-feature-list { - color: var(--primary-medium); - } -} - -.ai-persona-tool-option-editor { - &__instructions { - color: var(--primary-medium); - font-size: var(--font-down-1); - line-height: var(--line-height-large); - } -} - -.ai-personas__container { - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - width: 100%; -} - -.ai-persona-editor { - padding-left: 0.5em; - - &__tool-options { - padding: 1em; - border: 1px solid var(--primary-low-mid); - width: 480px; - } - - &__tool-options-name { - margin-bottom: 10px; - font-size: var(--font-down-1); - } - - &__response-format { - width: 100%; - display: block; - } - - &__response-format-pre { - margin-bottom: 0; - white-space: pre-line; - } - - &__response-format-none { - margin-bottom: 1em; - margin-top: 0.5em; - } -} - -.rag-options { - &__indexing-options { - display: block; - margin-top: 1em; - margin-bottom: 1em; - } -} - -.rag-uploader { - &__search-input { - display: flex; - align-items: center; - border: 1px solid var(--primary-400); - width: 100%; - box-sizing: border-box; - height: 35px; - padding: 0 0.5rem; - - &:focus, - &:focus-within { - @include default-focus; - } - - &-container { - display: flex; - flex-grow: 1; - } - - &__search-icon { - background: none !important; - color: var(--primary-medium); - } - - &__input { - width: 100% !important; - } - - &__input, - &__input:focus { - margin: 0 !important; - border: 0 !important; - appearance: none !important; - outline: none !important; - background: none !important; - } - } - - &__uploads-list { - &:has(tr) { - margin-bottom: 20px; - } - - tbody { - border-top: none; - } - } - - &__upload-status { - text-align: right; - padding-right: 0; - - .indexed { - color: var(--success); - } - - .uploaded, - .indexing { - color: var(--primary-low-mid); - } - } - - &__remove-file { - text-align: right; - padding-left: 0; - } - - &__rag-file-icon { - margin-right: 5px; - } - - .hidden-upload-field { - visibility: hidden; - position: absolute; - } -} diff --git a/assets/stylesheets/modules/ai-bot/common/ai-tools.scss b/assets/stylesheets/modules/ai-bot/common/ai-tools.scss deleted file mode 100644 index acbe5d8c..00000000 --- a/assets/stylesheets/modules/ai-bot/common/ai-tools.scss +++ /dev/null @@ -1,64 +0,0 @@ -@use "lib/viewport"; - -.ai-tool-parameter { - padding: 1.5em; - border: 1px solid var(--primary-low-mid); - border-radius: var(--d-input-border-radius); - width: 100%; - box-sizing: border-box; - - .form-kit__container-content { - flex-direction: column; - width: 100%; - } -} - -.ai-tool-parameter__enum-values { - margin-block: 1rem; - - .form-kit__container-content { - display: grid; - grid-template-columns: 1fr auto; - position: relative; - - .form-kit__button.btn-icon-text { - justify-self: start; - grid-column: 1 / -1; - } - } -} - -.ai-tool-editor { - @include viewport.from(lg) { - max-width: 80%; - } - position: relative; - - #control-rag_uploads .rag-uploader { - h3, - p { - display: none; - } - } -} - -.ai-tool-test-modal { - &__test-result div { - ul { - padding-left: 1em; - } - } -} - -.ai-tool-list-editor { - &__header { - display: flex; - justify-content: space-between; - align-items: center; - margin: 0 0 1em 0; - - h3 { - margin: 0; - } - } -} diff --git a/assets/stylesheets/modules/ai-bot/common/bot-replies.scss b/assets/stylesheets/modules/ai-bot/common/bot-replies.scss deleted file mode 100644 index 9579b7bd..00000000 --- a/assets/stylesheets/modules/ai-bot/common/bot-replies.scss +++ /dev/null @@ -1,150 +0,0 @@ -nav.post-controls .actions button.cancel-streaming { - display: none; -} - -.ai-bot-chat { - #reply-control { - .user-selector, - .title-and-category, - #private-message-users { - display: none; - } - } - - .persona-llm-selector { - display: flex; - justify-content: space-between; - align-items: center; - - .select-kit.single-select.dropdown-select-box ul.select-kit-collection { - max-height: 200px; - } - margin-bottom: 1em; - } -} - -.ai-bot-pm { - .gpt-persona { - margin-bottom: 5px; - } - - #reply-control .composer-fields { - .mini-tag-chooser, - .add-warning { - display: none; - } - } -} - -.ai-bot-chat-warning { - color: var(--tertiary); - background-color: var(--tertiary-low); - border-top: 1px solid var(--tertiary-medium); - opacity: 0.75; - - .d-icon { - color: var(--tertiary); - } - margin: 0; - padding: 4px 10px; - width: calc(100% - 20px); -} - -article.streaming nav.post-controls .actions button.cancel-streaming { - display: inline-block; -} - -.ai-bot-available-bot-options { - padding: 0.5em; - - .ai-bot-available-bot-content { - color: var(--primary-high); - display: flex; - width: 100%; - min-width: 320px; - padding: 0.5em; - - .d-button-label { - flex: 1; - text-align: left; - } - - &:hover { - background: var(--primary-low); - } - } -} - -.topic-body .persona-flair { - order: 2; - font-size: var(--font-down-1); -} - -details.ai-quote { - > summary { - display: flex; - justify-content: space-between; - align-items: center; - - span:first-child { - margin-right: auto; - } - - span:nth-child(2) { - font-size: var(--font-down-2); - background: var(--primary-medium); - padding: 2px 6px 0; - color: var(--secondary); - } - } -} - -.ai-share-modal { - .d-modal__footer { - position: relative; - padding: 10px 20px 25px; - - .btn-primary { - margin-left: auto; - } - } - - &__just-copied { - position: absolute; - font-size: var(--font-down-1); - right: 20px; - bottom: 5px; - color: var(--success); - } -} - -span.onebox-ai-llm-title { - font-weight: bold; -} - -.d-modal.ai-debug-modal { - --modal-max-width: 99%; - - ul { - padding-left: 1em; - } - - li { - margin-bottom: 0.2em; - } - - li > ul { - margin-top: 0.2em; - margin-bottom: 0.2em; - } -} - -.ai-debug-modal__tokens__count { - display: block; -} - -.d-modal ul.ai-debug-modal__nav { - margin: 0 0 1em; - padding: 0; - border-bottom: none; -} diff --git a/assets/stylesheets/modules/ai-bot/mobile/ai-persona.scss b/assets/stylesheets/modules/ai-bot/mobile/ai-persona.scss deleted file mode 100644 index 6353f526..00000000 --- a/assets/stylesheets/modules/ai-bot/mobile/ai-persona.scss +++ /dev/null @@ -1,7 +0,0 @@ -.ai-persona-editor { - &__system_prompt, - &__description, - .select-kit.multi-select { - width: 100%; - } -} diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss deleted file mode 100644 index 20ef5909..00000000 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ /dev/null @@ -1,762 +0,0 @@ -@use "lib/viewport"; - -.composer-ai-helper-modal { - .inline-diff { - font-family: var(--d-font-family--monospace); - font-variant-ligatures: none; - } - - .text-preview, - .inline-diff { - ins { - background-color: var(--success-low); - text-decoration: none; - } - - del { - background-color: var(--danger-low); - text-decoration: line-through; - } - - mark { - background-color: var(--highlight-low); - border-bottom: 2px solid var(--highlight-high); - - ins, - del { - background: transparent; - text-decoration: none; - } - } - - .same-word { - color: var(--primary); - } - - .ghost { - color: var(--primary-low-mid); - } - - .preview-area { - height: 200px; - } - } - - @keyframes fadeOpacity { - 0% { - opacity: 1; - } - - 100% { - opacity: 0.5; - } - } - - &__loading { - animation: fadeOpacity 1.5s infinite alternate; - } - - &__old-value { - white-space: pre-wrap; - border-left: 2px solid var(--danger); - padding-left: 1rem; - color: var(--danger); - margin-bottom: 1rem; - } - - &__new-value { - border-left: 2px solid var(--success); - padding-left: 1rem; - color: var(--success); - } - - .d-modal__footer { - .regenerate { - margin-left: auto; - } - } -} - -.topic-above-suggested-outlet.related-topics { - margin: 4.5em 0 1em; -} - -.ai-composer-helper-menu { - max-width: 25rem; - list-style: none; - - ul { - margin: 0; - list-style: none; - } -} - -.ai-custom-prompt { - display: flex; - gap: 0.25rem; - padding: 0.75em 1rem; - - &__input[type="text"] { - border-color: var(--primary-400); - margin-bottom: 0; - - &::placeholder { - color: var(--primary-medium); - } - } -} - -.ai-helper-loading { - display: flex; - padding: 0.5rem; - gap: 1rem; - justify-content: flex-start; - align-items: center; - - .dot-falling { - margin-inline: 1rem; - margin-left: 1.5rem; - } -} - -.d-editor-input.loading { - animation: loading-text 1.5s infinite linear; -} - -@keyframes loading-text { - 0% { - color: var(--primary); - } - - 50% { - color: var(--tertiary); - } - - 100% { - color: var(--primary); - } -} - -.ai-helper-highlighted-selection { - background-color: var(--highlight-low-or-medium); -} - -// AI Typing indicator (taken from: https://github.com/nzbin/three-dots) -.dot-falling { - position: relative; - left: -9999px; - width: 10px; - height: 10px; - border-radius: 5px; - background-color: var(--tertiary); - color: var(--tertiary); - box-shadow: 9999px 0 0 0 var(--tertiary); - animation: dot-falling 1s infinite linear; - animation-delay: 0.1s; -} - -.dot-falling::before, -.dot-falling::after { - content: ""; - display: inline-block; - position: absolute; - top: 0; -} - -.dot-falling::before { - width: 10px; - height: 10px; - border-radius: 5px; - background-color: var(--tertiary); - color: var(--tertiary); - animation: dot-falling-before 1s infinite linear; - animation-delay: 0s; -} - -.dot-falling::after { - width: 10px; - height: 10px; - border-radius: 5px; - background-color: var(--tertiary); - color: var(--tertiary); - animation: dot-falling-after 1s infinite linear; - animation-delay: 0.2s; -} - -@keyframes dot-falling { - 0% { - box-shadow: 9999px -15px 0 0 rgb(152, 128, 255, 0); - } - - 25%, - 50%, - 75% { - box-shadow: 9999px 0 0 0 var(--tertiary); - } - - 100% { - box-shadow: 9999px 15px 0 0 rgb(152, 128, 255, 0); - } -} - -@keyframes dot-falling-before { - 0% { - box-shadow: 9984px -15px 0 0 rgb(152, 128, 255, 0); - } - - 25%, - 50%, - 75% { - box-shadow: 9984px 0 0 0 var(--tertiary); - } - - 100% { - box-shadow: 9984px 15px 0 0 rgb(152, 128, 255, 0); - } -} - -@keyframes dot-falling-after { - 0% { - box-shadow: 10014px -15px 0 0 rgb(152, 128, 255, 0); - } - - 25%, - 50%, - 75% { - box-shadow: 10014px 0 0 0 var(--tertiary); - } - - 100% { - box-shadow: 10014px 15px 0 0 rgb(152, 128, 255, 0); - } -} - -// Suggest Titles Related -.showing-ai-suggestions { - .title-input { - // border on focus should be on top of suggestion button - input:focus { - z-index: 1; - } - } - - #edit-title { - padding-right: 2em; - } - - .category-chooser { - .select-kit-header-wrapper { - padding-right: 1.5em; - } - } - - .mini-tag-chooser { - .multi-select-header { - padding-right: 2em; - } - } - - .select-kit.is-expanded { - // need to raise the z-index so the sibling input buttons don't cover the dropdown - z-index: z("dropdown") + 1; - - + button { - z-index: z("dropdown") + 1; - } - } -} - -.suggestion-button { - .d-icon-spinner { - animation: spin 1s linear infinite; - } -} - -.edit-title__wrapper, -.edit-category__wrapper, -.edit-tags__wrapper { - position: relative; -} - -.suggest-titles-button, -.suggest-tags-button, -.suggest-category-button { - position: absolute; - right: 0; - top: 1px; // container border width - z-index: z("dropdown"); - - #reply-control & { - z-index: z("composer", "dropdown") + 1; - } -} - -#reply-control { - .composer-actions.is-expanded, - .composer-popup { - // need to raise the z-index here - // because we need another layer to put the AI icon above dropdowns - // while also keeping them below the composer tips - z-index: z("composer", "dropdown") + 2; - } - - .with-category .showing-ai-suggestions .category-input { - flex-wrap: nowrap; - max-width: calc(50% - 0.2em); - - .category-chooser { - min-width: 0; - flex: 1 1 auto; - } - } - - .with-category:not(.with-tags) { - // when tagging is disabled - .showing-ai-suggestions .category-input { - max-width: 40%; - } - } - - .with-tags { - .showing-ai-suggestions .tags-input { - display: flex; - max-width: calc(50% - 0.2em); - - .mini-tag-chooser { - min-width: 0; - } - } - } - - .showing-ai-suggestions { - #reply-title { - padding-right: 2em; - } - } -} - -.ai-category-suggester-content, -.ai-tag-suggester-content, -.ai-title-suggester-content { - z-index: z("composer", "dropdown"); -} - -.ai-suggestions-menu .btn { - text-align: left; -} - -.mobile-view { - .ai-category-suggester-content, - .ai-tag-suggester-content, - .ai-title-suggester-content { - z-index: z("modal", "dropdown"); - } -} - -.ai-category-suggester-content { - .category-row { - padding: 0.25em 0.5em; - color: var(--primary-high); - - &:hover { - background: var(--d-hover); - } - } - - .topic-count { - font-size: var(--font-down-2); - } -} - -.ai-tag-suggester-content { - .tag-row { - .discourse-tag-count { - margin-left: 5px; - } - - .d-button-label { - display: none; - } - } -} - -.edit-topic-title { - .suggestion-button { - margin: 0; - padding: 0.465rem; - } -} - -#topic-title .edit-topic-title.showing-ai-suggestions { - #edit-title { - flex: 1 1 90%; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - .suggest-titles-button { - padding: 0.5rem; - } -} - -.suggest-tags-button + .ai-suggestions-menu { - top: 4.25rem; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(359deg); - } -} - -.ai-post-helper { - &__suggestion { - display: flex; - flex-direction: column; - - h2 { - font-size: var(--font-0); - border-bottom: 1px solid var(--primary-low); - padding-bottom: 0.5em; - } - - p { - margin: 0; - } - - &__copy { - .d-icon-check { - color: var(--success); - } - } - - &__text { - padding: 0.5rem; - } - - &__buttons { - display: flex; - align-items: center; - gap: 0.5rem; - - .btn { - flex-grow: 1; - padding-inline: 0; - } - } - } - - &__fast-edit { - .fast-edit-container { - padding: 0.75em 1rem; - } - } -} - -.choose-topic-modal .split-new-topic-form { - .control-group { - display: flex; - flex-flow: row wrap; - align-items: center; - gap: 0.25em; - margin-bottom: 1rem; - - label { - flex: 100%; - } - - input, - .combo-box, - .multi-select { - flex: 1; - margin-bottom: 0; - } - } - - .ai-split-topic-suggestion-button { - .d-icon-spinner { - animation: spin 1s linear infinite; - } - } -} - -.ai-split-topic-suggestion__results { - list-style: none; - margin: 0; - - .btn { - display: block; - width: 100%; - text-align: left; - background: none; - - &:hover, - &:focus { - background: var(--d-hover); - color: var(--primary); - } - } - - li:not(:last-child) { - border-bottom: 1px solid var(--primary-low); - } - - .ai-split-topic-suggestion__category-result { - font-size: var(--font-0); - padding: 0.5em 1rem; - - &:hover, - &:focus { - background: var(--d-hover); - cursor: pointer; - } - } - - .topic-count { - font-size: var(--font-down-2); - color: var(--primary-high); - } -} - -.fk-d-menu[data-identifier="ai-split-topic-suggestion-menu"] { - z-index: z("modal", "dropdown"); -} - -.ai-split-topic-loading-placeholder { - .d-icon-spinner { - animation: spin 1s linear infinite; - } - - + .ai-split-topic-suggestion-button { - display: none; - } -} - -.thumbnail-suggestions-modal { - .ai-thumbnail-suggestions { - display: flex; - flex-flow: row wrap; - position: relative; - gap: 0.5em; - - &__item { - flex: 35%; - position: relative; - } - - img { - width: 100%; - height: auto; - } - - .btn { - position: absolute; - top: 0.5rem; - left: 0.5rem; - } - } -} - -// AI Image Caption Feature: -.image-wrapper { - .button-wrapper { - .generate-caption { - background: var(--tertiary-low); - color: var(--tertiary); - box-shadow: var(--shadow-dropdown); - position: absolute; - white-space: nowrap; - top: -2rem; - left: 0.35rem; - padding: 0.33em 0.75em; - transition: all 0.25s ease; - - .discourse-no-touch & { - display: none; - } - - .d-icon { - margin-right: 0.25rem; - } - - &:active { - box-shadow: none; - } - - &:hover, - &:focus { - background: var(--tertiary-400); - color: var(--tertiary-hover); - cursor: pointer; - } - - &.disabled { - pointer-events: none; - cursor: not-allowed; - opacity: 0.7; - } - } - } - - .discourse-no-touch & { - &:hover { - .button-wrapper .generate-caption { - display: block; - } - } - } -} - -.ai-caption-popup { - --ai-caption-popup-min-width: 20rem; - width: auto; - right: unset; - padding: 1em; - top: unset; - bottom: 0; - - .loading-container { - min-width: var(--ai-caption-popup-min-width); - } - - textarea { - box-sizing: border-box; - width: 100%; - max-width: 40dvw; - max-height: calc(100dvh - var(--header-offset) - 10em); - min-height: 3em; - height: 7em; - min-width: var(--ai-caption-popup-min-width); - - @include viewport.until(md) { - width: 100%; - max-width: unset; - min-width: unset; - } - } - - .actions { - display: flex; - align-items: center; - gap: 0.5rem; - - .credits { - font-size: var(--font-down-1); - margin-left: auto; - color: var(--tertiary); - - .desktop-view & { - // a little extra space for extra narrow desktop view - @media screen and (max-width: 675px) { - span { - display: none; - } - } - } - } - } - - .spinner { - border-color: var(--tertiary-600); - border-right-color: var(--tertiary); - } -} - -.ai-image-caption-prompt-dialog { - .dialog-content { - max-width: 555px; - } -} - -.auto-image-caption-loader { - margin-left: 2rem; - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--primary-high); -} - -// AI Helper Options List -.ai-helper-options { - margin: 0; - list-style: none; - - li { - display: flex; - align-items: center; - - .shortcut { - border: none; - background: none; - font-size: var(--font-down-1); - color: var(--primary-low-mid); - margin-left: auto; - } - } - - &__button { - justify-content: left; - text-align: left; - background: none; - width: 100%; - border-radius: 0; - margin: 0; - padding: 0.7rem 1rem; - - &:focus, - &:hover { - color: var(--primary); - background: var(--d-hover); - - .discourse-no-touch & { - color: var(--primary); - background: var(--d-hover); - } - - .d-icon { - color: var(--primary-high); - - .discourse-no-touch & { - color: var(--primary-high); - } - } - } - } -} - -.fk-d-menu[data-identifier="ai-composer-helper-menu"], -.fk-d-menu[data-identifier="ai-title-suggester"] { - z-index: z("modal", "dialog"); - - .fullscreen-composer & { - z-index: z("header") + 1; - } - - .mobile-view & { - z-index: z("mobile-composer"); - } -} - -.fk-d-toasts:has(.ai-proofread-error-toast) { - top: unset; - bottom: calc(var(--composer-height) - 5%); - right: unset; - left: 0; -} - -@media screen and (min-width: $reply-area-max-width) { - .has-sidebar-page { - .fk-d-toasts:has(.ai-proofread-error-toast) { - transform: translateX( - calc( - (100vw - var(--d-max-width) - var(--d-sidebar-width) / 0.5) / 2 + - 17em + 1rem - ) - ); - } - } -} diff --git a/assets/stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss b/assets/stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss deleted file mode 100644 index f2fe3e1a..00000000 --- a/assets/stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss +++ /dev/null @@ -1,9 +0,0 @@ -.fk-d-menu { - .ai-post-helper { - &__suggestion__text, - &__suggestion__buttons { - padding: 0.75em 1rem; - margin: 0; - } - } -} diff --git a/assets/stylesheets/modules/ai-helper/mobile/ai-helper.scss b/assets/stylesheets/modules/ai-helper/mobile/ai-helper.scss deleted file mode 100644 index 2e16f4ae..00000000 --- a/assets/stylesheets/modules/ai-helper/mobile/ai-helper.scss +++ /dev/null @@ -1,74 +0,0 @@ -.fk-d-menu-modal { - &.ai-post-helper-menu-content { - .ai-post-helper { - &__suggestion__text, - &__suggestion__buttons { - padding: 0.75em 1rem; - margin: 0; - } - } - - .d-modal__body { - display: flex; - flex-direction: column; - max-height: 100%; - } - - .ai-helper-options { - padding: 0; - } - - .ai-custom-prompt { - padding: 0.75em 1rem; - margin: 0; - } - - .ai-helper-loading { - justify-content: center; - } - } - - .ai-post-helper-menu, - .ai-composer-helper-menu { - &__selected-text { - margin: 0.75em 1rem; - padding: 0.5em; - border-radius: var(--d-border-radius); - border: 1px solid var(--primary-low); - box-shadow: 0 0 4px rgba(0, 0, 0, 0.125); - overflow: auto; - overscroll-behavior: contain; - } - } -} - -#topic-title .edit-topic-title.showing-ai-suggestions { - .category-chooser { - flex: 1 1 90%; - } - - .ai-category-suggester-trigger { - padding: 0.425em; - } - - .ai-tag-suggester-trigger { - padding: 0.45em; - } -} - -.ios-device #topic-title .edit-topic-title.showing-ai-suggestions button { - &.ai-title-suggester-trigger { - padding-top: 0.4em; - padding-bottom: 0.4em; - } - - &.ai-category-suggester-trigger { - padding-top: 0.4em; - padding-bottom: 0.25em; - } - - &.ai-tag-suggester-trigger { - padding-top: 0.4em; - padding-bottom: 0.3em; - } -} diff --git a/assets/stylesheets/modules/embeddings/common/ai-embedding-editor.scss b/assets/stylesheets/modules/embeddings/common/ai-embedding-editor.scss deleted file mode 100644 index cda3c483..00000000 --- a/assets/stylesheets/modules/embeddings/common/ai-embedding-editor.scss +++ /dev/null @@ -1,74 +0,0 @@ -.ai-embedding-editor { - padding-left: 0.5em; - - .ai-embedding-editor-input { - width: 350px; - } - - .ai-embedding-editor-tests { - &__failure { - color: var(--danger); - } - - &__success { - color: var(--success); - } - } - - &__api-key { - margin-right: 0.5em; - } - - &__secret-api-key-group { - display: flex; - align-items: center; - } - - &__matryoshka_dimensions { - display: flex; - align-items: flex-start; - } - - &__distance_functions.select-kit { - .selected-name { - .d-icon { - width: 2em; - height: 2em; - position: absolute; - - + .name { - margin-left: 2.25em; - } - } - } - - .svg-icon-title { - width: 2em; - top: -0.5em; - - svg { - width: 2em; - height: 2em; - } - } - } -} - -.discourse-ai-embeddings { - .btn-flat.back-button { - padding-left: 0; - } - - .fk-d-tooltip__icon { - margin-left: 0.25em; - color: var(--primary-medium); - } - - textarea + .fk-d-tooltip__trigger { - vertical-align: top; - } - - .d-icon-circle-exclamation { - color: var(--danger); - } -} diff --git a/assets/stylesheets/modules/embeddings/common/semantic-related-topics.scss b/assets/stylesheets/modules/embeddings/common/semantic-related-topics.scss deleted file mode 100644 index 63fffe77..00000000 --- a/assets/stylesheets/modules/embeddings/common/semantic-related-topics.scss +++ /dev/null @@ -1,15 +0,0 @@ -.related-topics { - margin: 4.5em 0 1em; -} - -.more-topics__container { - h3 .d-icon { - margin-right: 0.25em; - color: var(--primary-high); - font-size: var(--font-down-1); - } - - .nav-pills .d-icon { - font-size: var(--font-down-1); - } -} diff --git a/assets/stylesheets/modules/embeddings/common/semantic-search.scss b/assets/stylesheets/modules/embeddings/common/semantic-search.scss deleted file mode 100644 index e3677291..00000000 --- a/assets/stylesheets/modules/embeddings/common/semantic-search.scss +++ /dev/null @@ -1,104 +0,0 @@ -@use "lib/viewport"; - -.semantic-search__container { - margin: 1rem 0 0 0; - - .has-sidebar-page & { - @include viewport.until(lg) { - border-top: 1px solid var(--primary-low); - } - } - - body:not(.has-sidebar-page) & { - @include viewport.until(md) { - border-top: 1px solid var(--primary-low); - } - } - - .semantic-search__results { - display: flex; - flex-direction: column; - align-items: baseline; - - .ai-indicator-wave { - color: var(--primary-medium); - } - - .semantic-search { - &__searching { - display: flex; - align-items: center; - - &.in-progress, - &.unavailable { - .semantic-search__searching-text { - color: var(--primary-medium); - } - } - - svg { - font-size: var(--font-down-1); - color: var(--primary-high); - } - } - - &__searching-text { - display: inline-block; - margin-left: 8px; - } - - &__tooltip { - margin-left: 4px; - font-size: var(--font-down-1); - } - - &__entries { - margin-top: 10px; - } - } - } -} - -.search-results { - .fps-result { - padding: 0.5rem; - - .ai-result__icon { - display: none; - } - } - - .ai-result { - border-radius: var(--d-border-radius); - - .ai-result__icon { - display: inline; - margin-right: 0.5rem; - margin-left: auto; - font-size: var(--font-up-2); - color: var(--tertiary); - } - } -} - -// Hides other buttons and only shows loader -// while AI quick search is in progress -.search-input { - .ai-quick-search-spinner ~ a.clear-search, - .ai-quick-search-spinner ~ a.show-advanced-search { - display: none; - } -} - -@include viewport.until(md) { - .search-container .search-advanced .semantic-search__container { - + .search-info { - padding-inline: 1rem; - } - - &.search-results { - margin-bottom: 0; - padding-inline: 1rem; - } - } -} diff --git a/assets/stylesheets/modules/llms/common/ai-llm-quotas.scss b/assets/stylesheets/modules/llms/common/ai-llm-quotas.scss deleted file mode 100644 index 30e1e592..00000000 --- a/assets/stylesheets/modules/llms/common/ai-llm-quotas.scss +++ /dev/null @@ -1,71 +0,0 @@ -.ai-llm-quotas { - margin: 1em 0; - - &__table { - width: 100%; - border-collapse: collapse; - margin-bottom: 1em; - } - - &__table-head { - background-color: var(--primary-very-low); - } - - .duration-selector { - .select-kit { - width: 150px; - } - } - - .duration-selector__custom { - margin-top: 8px; - } - - &__header { - text-align: left; - padding: 0.5em; - font-weight: bold; - border-bottom: 2px solid var(--primary-low); - - &--actions { - width: 50px; - } - } - - &__row { - border-bottom: 1px solid var(--primary-low); - } - - &__cell { - vertical-align: middle; - align-items: center; - - &--actions { - text-align: center; - } - } - - &__input[type="number"] { - width: 200px; - padding: 0.5em; - margin-bottom: 0; - } - - &__group-select { - width: 200px; - } - - &__delete-btn { - padding: 0.3em 0.5em; - } - - &__add-btn { - padding: 0.3em 0.5em; - } -} - -.ai-llm-quota-modal { - .fk-d-tooltip__icon { - color: var(--primary-medium); - } -} diff --git a/assets/stylesheets/modules/llms/common/ai-llms-editor.scss b/assets/stylesheets/modules/llms/common/ai-llms-editor.scss deleted file mode 100644 index ac7d8669..00000000 --- a/assets/stylesheets/modules/llms/common/ai-llms-editor.scss +++ /dev/null @@ -1,142 +0,0 @@ -.ai-llms-list-editor { - &__header { - display: flex; - justify-content: space-between; - align-items: center; - margin: 0 0 1em 0; - } - - &__configured, - &__templates { - margin-top: 2em; - - h2 { - font-size: var(--font-up-1); - } - } -} - -.ai-llm-editor { - padding-left: 0.5em; - - .ai-llm-editor-tests { - &__failure { - color: var(--danger); - } - - &__success { - color: var(--success); - } - } -} - -[class*="ai-llms-list-editor"] { - h3 { - font-weight: normal; - margin: 0; - line-height: var(--line-height-medium); - } -} - -.ai-llms-list-editor__configured { - .d-toggle-switch { - justify-content: center; - } -} - -.ai-tool-list-editor__current, -.ai-persona-list-editor__current, -.ai-llms-list-editor__configured { - .d-admin-table { - tr:hover { - background: inherit; - } - - @include breakpoint("tablet", min-width) { - th, - td { - &:first-child { - padding-left: 0; - } - - &:last-child { - padding-right: 0; - } - } - } - } -} - -.ai-llms-list-editor__templates { - &-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(16em, 1fr)); - gap: 1em 2em; - margin-top: 1em; - padding-top: 1em; - border-top: 3px solid var(--primary-low); - } - - &-list-item { - display: grid; - grid-template-rows: subgrid; - grid-row: span 4; - gap: 0; - margin-bottom: 2em; - - @include breakpoint("mobile-extra-large", min-width) { - margin-bottom: 3em; - } - } - - .admin-section-landing-item__description { - color: var(--primary-high); - margin: 0.25em 0 0.5em; - line-height: var(--line-height-large); - align-self: start; - - @include breakpoint("mobile-extra-large", min-width) { - max-width: 17em; - } - } - - button { - justify-self: start; - } - - h4 { - font-size: var(--font-down-1); - font-weight: normal; - color: var(--primary-high); - margin: 0; - letter-spacing: 0.1px; - } -} - -.ai-llm-list-editor__usages { - list-style: none; - margin: 0.5em 0 0 0; - display: flex; - flex-wrap: wrap; - - li { - font-size: var(--font-down-2); - border-radius: 0.25em; - background: var(--primary-very-low); - border: 1px solid var(--primary-low); - padding: 1px 3px; - margin-right: 0.5em; - margin-bottom: 0.5em; - } -} - -.ai-llm-list__seeded-model { - color: var(--primary-high); - font-size: var(--font-down-1); -} - -@include breakpoint("tablet") { - .ai-llm-list__description { - max-width: 80%; - } -} diff --git a/assets/stylesheets/modules/llms/common/spam.scss b/assets/stylesheets/modules/llms/common/spam.scss deleted file mode 100644 index 4c638007..00000000 --- a/assets/stylesheets/modules/llms/common/spam.scss +++ /dev/null @@ -1,107 +0,0 @@ -.ai-spam { - --chart-scanned-color: var(--success); - --chart-spam-color: var(--danger); - padding-top: 15px; - - &__settings { - margin-bottom: 2em; - } - - &__enabled { - display: flex; - align-items: center; - gap: 0.4em; - margin-bottom: 1em; - - .fk-d-tooltip__trigger { - color: var(--primary-high); - } - } - - &__settings-title { - margin-bottom: 1em; - } - - &__toggle, - &__llm, - &__persona, - &__instructions { - margin-bottom: 1em; - } - - &__toggle-label, - &__llm-label, - &__persona-label, - &__instructions-label { - display: block; - margin-bottom: 0.5em; - font-weight: bold; - } - - &__instructions-input { - width: 100%; - min-height: 100px; - margin-bottom: 0.5em; - } - - &__stats { - margin-top: 2em; - } - - &__errors { - .alert { - display: flex; - align-items: center; - gap: 0.5rem; - - .btn { - margin-left: auto; - } - } - } -} - -.spam-test-modal { - &__body { - min-width: 500px; - } - - &__test-result { - margin-top: 1.5em; - padding-top: 1.5em; - border-top: 1px solid var(--primary-low); - } - - &__verdict { - font-size: var(--font-up-2); - font-weight: bold; - padding: 0.5em; - border-radius: 0.25em; - text-align: center; - margin: 1em 0; - - &.is-spam { - background: var(--danger-low); - color: var(--danger); - } - - &.not-spam { - background: var(--success-low); - color: var(--success); - } - } - - &__log { - margin-top: 1em; - - pre { - max-height: 300px; - overflow-y: auto; - background: var(--primary-very-low); - padding: 1em; - margin: 0.5em 0; - font-family: monospace; - white-space: pre-wrap; - } - } -} diff --git a/assets/stylesheets/modules/llms/common/usage.scss b/assets/stylesheets/modules/llms/common/usage.scss deleted file mode 100644 index 228decc4..00000000 --- a/assets/stylesheets/modules/llms/common/usage.scss +++ /dev/null @@ -1,154 +0,0 @@ -.ai-usage { - --chart-response-color: rgba(75, 192, 192, 0.8); - --chart-request-color: rgba(153, 102, 255, 0.8); - --chart-cached-color: rgba(153, 102, 255, 0.4); - padding: 1em; - - &__filters-dates { - display: flex; - flex-direction: column; - gap: 1em; - margin-bottom: 1em; - } - - &__period-buttons { - display: flex; - gap: 0.5em; - align-items: center; - - .btn { - padding: 0.5em 1em; - - &.btn-primary { - background: var(--tertiary); - color: var(--secondary); - } - } - } - - &__custom-date-pickers { - display: flex; - gap: 1em; - align-items: center; - margin-top: 0.5em; - } - - &__filters { - margin-bottom: 2em; - } - - &__filters-period { - display: flex; - align-items: center; - gap: 1em; - } - - .d-date-time-input-range { - display: flex; - gap: 1em; - align-items: center; - } - - .d-date-time-input-range .from { - margin: 0; - } - - &__period-label { - font-weight: bold; - } - - &__summary { - margin: 2em 0; - } - - &__summary-title { - margin-bottom: 1em; - color: var(--primary); - font-size: 1.2em; - } - - &__charts { - margin-top: 2em; - } - - &__chart { - position: relative; - } - - &__chart-container { - margin-bottom: 2em; - } - - &__chart-title { - margin-bottom: 1em; - } - - &__breakdowns { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2em; - margin-top: 2em; - - @media (max-width: 768px) { - grid-template-columns: none; - display: flex; - flex-direction: column; - } - } - - &__users { - grid-column: span 2; - - .admin-config-area-card__content { - display: flex; - - .ai-usage__users-table { - &:first-child { - margin-right: 2em; - - &.-double-width { - margin-right: 0; - } - } - - &.-double-width { - .ai-usage__users-username { - width: auto; - } - } - - .ai-usage__users-username { - width: 50px; - } - } - } - } - - &__features-table, - &__users-table, - &__models-table { - width: 100%; - border-collapse: collapse; - - th { - text-align: left; - padding: 0.5em; - border-bottom: 2px solid var(--primary-low); - } - } - - &__features-row, - &__users-row, - &__models-row { - &:hover { - background: var(--primary-low); - } - } - - &__features-cell, - &__users-cell, - &__models-cell { - padding: 0.5em; - border-bottom: 1px solid var(--primary-low); - } -} diff --git a/assets/stylesheets/modules/sentiment/common/dashboard.scss b/assets/stylesheets/modules/sentiment/common/dashboard.scss deleted file mode 100644 index 4fd9d412..00000000 --- a/assets/stylesheets/modules/sentiment/common/dashboard.scss +++ /dev/null @@ -1,301 +0,0 @@ -.dashboard.dashboard-sentiment { - .charts { - display: grid; - grid-template-columns: repeat(12, 1fr); - grid-column-gap: 1em; - grid-row-gap: 1em; - - .admin-report { - grid-column: span 12; - } - } -} - -@mixin report-container-box() { - border: 1px solid var(--primary-low); - border-radius: var(--d-border-radius); - padding: 1rem; -} - -.admin-report.sentiment-analysis .body { - display: flex; - flex-flow: row wrap; - gap: 1rem; - - .filters { - @include report-container-box; - order: 1; - width: 100%; - margin-left: 0; - flex-flow: row wrap; - align-items: flex-start; - justify-content: flex-start; - gap: 0.5rem; - - .control { - min-width: 200px; - } - - .control:nth-of-type(-n + 4) { - flex: 1; - } - - // Hides tag selector when showing subcategories selector - .control:nth-of-type(6):nth-last-of-type(3) { - display: none; - } - - .control:last-child { - align-self: flex-end; - } - - .control:has(.export-csv-btn) { - display: none; - } - } - - .main { - flex: 100%; - display: flex; - flex-flow: row wrap; - order: 2; - align-items: flex-start; - max-height: 100vh; - } -} - -.admin-report-sentiment-analysis { - @include report-container-box; - flex: 2; - display: flex; - gap: 2rem 1rem; - justify-content: space-evenly; - align-items: center; - flex-flow: row wrap; - padding-inline: 0; - padding-block: 1.5rem; - - .admin-report-doughnut { - padding: 0.25rem; - } - - &__chart-wrapper { - height: fit-content; - position: relative; - transition: - transform 0.25s ease, - box-shadow 0.25s ease; - border-radius: var(--d-border-radius); - - .doughnut-chart-title { - @include ellipsis; - margin: 0 auto; - margin-top: 1rem; - text-align: center; - } - - &:hover { - box-shadow: var(--shadow-card); - transform: translateY(-0.5rem); - cursor: pointer; - } - } - - &__selected-chart { - border: 1px solid var(--primary-low); - border-radius: var(--d-border-radius); - padding: 1rem; - - .doughnut-chart-title { - font-size: var(--font-up-2); - margin: 0 auto; - text-align: center; - margin-top: 0.3rem; - padding-top: 2rem; - } - } - - &__selected-chart-actions { - display: flex; - align-items: center; - padding-bottom: 0.35rem; - border-bottom: 1px solid var(--primary-low); - - .share { - margin-left: auto; - - .d-icon-check { - color: var(--success); - } - } - } -} - -:root { - --d-sentiment-report-positive-rgb: 46, 204, 112; - --d-sentiment-report-neutral-rgb: 149, 166, 167; - --d-sentiment-report-negative-rgb: 231, 77, 60; -} - -.admin-report-sentiment-analysis-details { - @include report-container-box; - flex: 1 1 300px; - min-width: 300px; - margin-left: 1rem; - display: flex; - flex-flow: column nowrap; - overflow-y: auto; - height: 100%; - padding-top: 0; - - &__filters { - border-bottom: 1px solid var(--primary-low); - margin-bottom: 1rem; - - @include breakpoint("mobile-extra-large") { - .d-button-label { - display: none; - } - } - } - - &__scores { - display: flex; - flex-flow: column wrap; - align-items: flex-start; - justify-content: flex-start; - gap: 0.25rem; - list-style: none; - margin-left: 0; - background: var(--primary-very-low); - padding: 1rem; - border-radius: var(--d-border-radius); - - .d-icon-face-smile { - color: rgb(var(--d-sentiment-report-positive-rgb)); - } - - .d-icon-face-meh { - color: rgb(var(--d-sentiment-report-neutral-rgb)); - } - - .d-icon-face-angry { - color: rgb(var(--d-sentiment-report-negative-rgb)); - } - } - - &__post-score { - border-radius: var(--d-border-radius); - background: var(--primary-very-low); - margin-top: 0.5rem; - padding: 0.25rem; - font-size: var(--font-down-1); - display: inline-block; - - &[data-sentiment-score="positive"] { - color: rgb(var(--d-sentiment-report-positive-rgb)); - background: rgba(var(--d-sentiment-report-positive-rgb), 0.1); - } - - &[data-sentiment-score="neutral"] { - color: rgb(var(--d-sentiment-report-neutral-rgb)); - background: rgba(var(--d-sentiment-report-neutral-rgb), 0.1); - } - - &[data-sentiment-score="negative"] { - color: rgb(var(--d-sentiment-report-negative-rgb)); - background: rgba(var(--d-sentiment-report-negative-rgb), 0.1); - } - } - - &__post-list { - margin-top: 1rem; - - .avatar-wrapper, - .avatar-link { - width: calc(48px * 0.75); - height: calc(48px * 0.75); - } - - img.avatar { - width: 100%; - height: 100%; - } - } -} - -.admin-reports.admin-contents .sentiment-analysis { - .horizontal-overflow-nav { - background: var(--secondary); - position: sticky; - top: 0; - padding-top: 1rem; - z-index: z("timeline"); - } -} - -.showing-sentiment-analysis-chart - .admin-report.sentiment-analysis - .body - .filters { - // Hide elements 2 - 6 when showing selected chart - // as they're not supported being changed in this view - .control:first-of-type { - flex: unset; - } - - .control:nth-of-type(n + 2):nth-of-type(-n + 6) { - display: none; - } -} - -.sentiment-analysis-table { - margin: 1rem; - - &__total-score { - font-weight: bold; - font-size: var(--font-up-1); - } - - &__row { - cursor: pointer; - } -} - -.sentiment-horizontal-bar { - display: flex; - - &__count { - font-weight: bold; - font-size: var(--font-down-1); - color: var(--secondary); - } - - &__positive, - &__neutral, - &__negative { - display: flex; - flex-flow: column nowrap; - justify-content: flex-end; - align-items: center; - padding: 0.75rem; - border-left: 2px solid var(--secondary); - border-right: 2px solid var(--secondary); - } - - &__positive { - background: rgb(var(--d-sentiment-report-positive-rgb)); - border-top-left-radius: var(--d-border-radius); - border-bottom-left-radius: var(--d-border-radius); - } - - &__negative { - background: rgb(var(--d-sentiment-report-negative-rgb)); - } - - &__neutral { - background: rgb(var(--d-sentiment-report-neutral-rgb)); - border-top-right-radius: var(--d-border-radius); - border-bottom-right-radius: var(--d-border-radius); - } -} diff --git a/assets/stylesheets/modules/summarization/common/ai-gists.scss b/assets/stylesheets/modules/summarization/common/ai-gists.scss deleted file mode 100644 index d7c7a398..00000000 --- a/assets/stylesheets/modules/summarization/common/ai-gists.scss +++ /dev/null @@ -1,74 +0,0 @@ -.topic-list-layout-content { - .btn.--with-description { - display: grid; - grid-template-areas: "icon title" "icon description"; - grid-template-columns: auto 1fr; - text-align: left; - - .btn__description { - grid-area: description; - width: 100%; - font-size: var(--font-down-1); - color: var(--primary-high); - } - } - - .btn:focus { - background: transparent; - } - - .btn:focus-visible { - outline: 2px solid var(--tertiary); - background: transparent; - outline-offset: -2px; - } - - .btn.--active { - background: var(--d-selected); - } -} - -.topic-list-layout-table-ai { - .topic-list-item { - .link-bottom-line { - font-size: var(--font-down-1); - margin-top: 0.25em; - line-height: var(--line-height-medium); - } - - .excerpt { - width: 100%; - line-height: var(--line-height-large); - margin-top: 0.15em; - margin-bottom: 0.15em; - color: currentcolor; - - &__contents { - max-width: 70ch; - overflow-wrap: break-word; - } - } - - &:not(.visited) { - .excerpt { - color: var(--primary-high); - } - } - } - - .topic-excerpt { - display: none; - } - - .mobile-view & { - .topic-list-item .excerpt { - margin-top: -0.25em; - margin-bottom: 0.25em; - } - - .topic-item-stats .num.activity { - align-self: end; - margin-bottom: -0.15em; // vertical alignment - } - } -} diff --git a/assets/stylesheets/modules/summarization/common/ai-summary.scss b/assets/stylesheets/modules/summarization/common/ai-summary.scss deleted file mode 100644 index 3b8301bf..00000000 --- a/assets/stylesheets/modules/summarization/common/ai-summary.scss +++ /dev/null @@ -1,102 +0,0 @@ -.topic-map { - // Hide the Top Replies label if summarization is enabled - &:has(.topic-map__additional-contents .ai-summarization-button) { - .top-replies { - .d-icon { - margin: 0; - height: 1.2em; - } - - .d-button-label { - display: none; - } - } - } - - // Hide the Summarize label when there are many stats - &:has(.--many-stats):has(.top-replies) .topic-map__additional-contents { - button { - .d-icon { - margin: 0; - height: 1.2em; - } - - .d-button-label { - display: none; - } - } - } -} - -.ai-summary-modal { - .ai-summary { - &__generating-text { - display: inline-block; - margin-left: 3px; - } - } - - .placeholder-summary { - padding-top: 0.5em; - } - - .placeholder-summary-text { - display: inline-block; - height: 1em; - margin-top: 0.6em; - width: 100%; - } - - .generated-summary p { - margin: 0; - } - - .outdated-summary { - display: flex; - flex-direction: column; - align-items: flex-end; - - button { - margin-top: 0.5em; - } - - p { - color: var(--primary-medium); - } - } - - .d-modal__footer { - display: grid; - gap: 0; - grid-template-areas: "summarized regenerate" " outdated regenerate"; - grid-template-columns: 1fr auto; - - @include breakpoint(mobile-large) { - gap: 0.25em 0.5em; - grid-template-areas: "summarized summarized" "regenerate outdated"; - } - - p { - margin: 0; - } - - .fk-d-tooltip__trigger { - vertical-align: text-top; - } - - .summary-outdated { - color: var(--primary-high); - font-size: var(--font-down-1); - line-height: var(--line-height-medium); - } - - .summarized-on { - grid-area: summarized; - } - - button { - grid-area: regenerate; - justify-self: start; - } - } -} diff --git a/assets/stylesheets/modules/summarization/desktop/ai-summary.scss b/assets/stylesheets/modules/summarization/desktop/ai-summary.scss deleted file mode 100644 index 6d8aed9d..00000000 --- a/assets/stylesheets/modules/summarization/desktop/ai-summary.scss +++ /dev/null @@ -1,26 +0,0 @@ -html.scrollable-modal { - overflow: auto; // overrides core .modal-open class scroll lock -} - -.ai-summary-modal { - .d-modal__container { - position: fixed; - top: var(--header-offset); - margin-top: 1em; - right: 1em; - width: 100vw; - max-width: 30em; - max-height: calc( - 100vh - var(--header-offset) - 3rem - var(--composer-height, 0px) - ); - box-shadow: var(--shadow-menu-panel); - } - - .fullscreen-composer & { - display: none; - } -} - -.ai-summary-modal + .d-modal__backdrop { - background: transparent; // allows for reading, but still triggers clickoutside event -} diff --git a/config/eval-llms.yml b/config/eval-llms.yml deleted file mode 100644 index 0c43dcc6..00000000 --- a/config/eval-llms.yml +++ /dev/null @@ -1,121 +0,0 @@ -llms: - o3: - display_name: O3 - name: o3 - tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer - api_key_env: OPENAI_API_KEY - provider: open_ai - url: https://api.openai.com/v1/chat/completions - max_prompt_tokens: 131072 - vision_enabled: true - provider_params: - disable_top_p: true - disable_temperature: true - - gpt-41: - display_name: GPT-4.1 - name: gpt-4.1 - tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer - api_key_env: OPENAI_API_KEY - provider: open_ai - url: https://api.openai.com/v1/chat/completions - max_prompt_tokens: 131072 - vision_enabled: true - - gpt-4o: - display_name: GPT-4o - name: gpt-4o - tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer - api_key_env: OPENAI_API_KEY - provider: open_ai - url: https://api.openai.com/v1/chat/completions - max_prompt_tokens: 131072 - vision_enabled: true - - gpt-4o-mini: - display_name: GPT-4o-mini - name: gpt-4o-mini - tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer - api_key_env: OPENAI_API_KEY - provider: open_ai - url: https://api.openai.com/v1/chat/completions - max_prompt_tokens: 131072 - vision_enabled: true - - claude-3.5-haiku: - display_name: Claude 3.5 Haiku - name: claude-3-5-haiku-latest - tokenizer: DiscourseAi::Tokenizer::AnthropicTokenizer - api_key_env: ANTHROPIC_API_KEY - provider: anthropic - url: https://api.anthropic.com/v1/messages - max_prompt_tokens: 200000 - vision_enabled: false - - claude-3.5-sonnet: - display_name: Claude 3.5 Sonnet - name: claude-3-5-sonnet-latest - tokenizer: DiscourseAi::Tokenizer::AnthropicTokenizer - api_key_env: ANTHROPIC_API_KEY - provider: anthropic - url: https://api.anthropic.com/v1/messages - max_prompt_tokens: 200000 - vision_enabled: true - - claude-3.7-sonnet: - display_name: Claude 3.7 Sonnet - name: claude-3-7-sonnet-latest - tokenizer: DiscourseAi::Tokenizer::AnthropicTokenizer - api_key_env: ANTHROPIC_API_KEY - provider: anthropic - url: https://api.anthropic.com/v1/messages - max_prompt_tokens: 200000 - vision_enabled: true - - claude-3.7-sonnet-thinking: - display_name: Claude 3.7 Sonnet - name: claude-3-7-sonnet-latest - tokenizer: DiscourseAi::Tokenizer::AnthropicTokenizer - api_key_env: ANTHROPIC_API_KEY - provider: anthropic - url: https://api.anthropic.com/v1/messages - max_prompt_tokens: 200000 - vision_enabled: true - provider_params: - disable_top_p: true - disable_temperature: true - enable_reasoning: true - reasoning_tokens: 1024 - - gemini-2.0-flash: - display_name: Gemini 2.0 Flash - name: gemini-2-0-flash - tokenizer: DiscourseAi::Tokenizer::GeminiTokenizer - api_key_env: GEMINI_API_KEY - provider: google - url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash - max_prompt_tokens: 1000000 - vision_enabled: true - - gemini-2.5-flash: - display_name: Gemini 2.5 Flash - name: gemini-2-5-flash - tokenizer: DiscourseAi::Tokenizer::GeminiTokenizer - api_key_env: GEMINI_API_KEY - provider: google - url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash - max_prompt_tokens: 1000000 - vision_enabled: true - provider_params: - disable_top_p: true - disable_temperature: true - - gemini-2.0-pro: - display_name: Gemini 2.0 pro - name: gemini-2-0-pro - tokenizer: DiscourseAi::Tokenizer::GeminiTokenizer - api_key_env: GEMINI_API_KEY - provider: google - url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-pro - max_prompt_tokens: 1000000 - vision_enabled: true diff --git a/config/locales/client.ar.yml b/config/locales/client.ar.yml deleted file mode 100644 index 2b724068..00000000 --- a/config/locales/client.ar.yml +++ /dev/null @@ -1,706 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ar: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "يسمح بالبحث باستخدام الذكاء الاصطناعي" - stream_completion: "يسمح بالتوليد التدريجي للنصوص لشخصيات الذكاء الاصطناعي" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "العاطفة" - description: "يسرد الجدول عدد المنشورات المصنَّفة بعاطفة محدَّدة. مصنَّفة باستخدام النموذج 'SamLowe/roberta-base-go_emotions'." - reports: - filters: - sort_by: - label: "الترتيب حسب" - tag: - label: "الوسم" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "المُرسِل" - description: "المستخدم الذي سيرسل التقرير" - receivers: - label: "المستلمون" - description: "المستخدمون الذين سيستلمون التقرير (سيتم إرسال رسائل إلكترونية مباشرة إلى عناوين البريد الإلكتروني، وسيتم إرسال رسائل خاصة إلى أسماء المستخدمين)" - topic_id: - label: "معرِّف الموضوع" - description: "معرِّف الموضوع الذي سيتم نشر التقرير فيه" - title: - label: "العنوان" - description: "عنوان التقرير" - days: - label: "الأيام" - description: "الفترة الزمنية للتقرير" - offset: - label: "الإزاحة" - description: "قد ترغب في تشغيل التقرير تاريخيًا عند الاختبار، استخدم الإزاحة لبدء التقرير في تاريخ سابق" - instructions: - label: "التعليمات" - description: "التعليمات المقدَّمة إلى النموذج اللغوي الكبير" - sample_size: - label: "حجم العينة" - description: "عدد المنشورات لعينة التقرير" - tokens_per_post: - label: "الرموز لكل منشور" - description: "عدد رموز النموذج اللغوي الكبير التي سيتم استخدامها لكل منشور" - model: - label: "النموذج" - description: "النموذج اللغوي الكبير لاستخدامه في إنشاء التقرير" - categories: - label: "الفئات" - description: "تصفية الموضوعات لهذه الفئات فقط" - tags: - label: "الوسوم" - description: "تصفية الموضوعات لهذه الوسوم فقط" - exclude_tags: - label: "استبعاد الوسوم" - description: "استبعاد الموضوعات التي تحتوي على هذه الوسوم" - exclude_categories: - label: "استبعاد الفئات" - description: "استبعاد الموضوعات التي تحتوي على هذه الفئات" - allow_secure_categories: - label: "السماح بالفئات الآمنة" - description: "السماح بإنشاء التقرير للموضوعات الموجودة في الفئات الآمنة" - suppress_notifications: - label: "منع الإشعارات" - description: "منع الإشعارات التي قد يُنشئها التقرير عن طريق التحويل إلى محتوى. سيؤدي هذا إلى إعادة رسم خريطة الإشارات والروابط الداخلية." - debug_mode: - label: "وضع تصحيح الأخطاء" - description: "فعِّل وضع تصحيح الأخطاء لرؤية المدخلات والمخرجات الأولية للنموذج اللغوي الكبير" - priority_group: - label: "مجموعة الأولوية" - description: "رتِّب أولوية المحتوى من هذه المجموعة في التقرير" - temperature: - label: "درجة الحرارة" - top_p: - label: "Top P" - llm_tool_triage: - fields: - model: - label: "النموذج" - llm_triage: - fields: - system_prompt: - label: "رسالة مطالبة النظام" - description: "رسالة المطالبة التي سيتم استخدامها للفرز، تأكَّد من رده بكلمة واحدة يمكنك استخدامها لبدء الإجراء" - max_post_tokens: - label: "الحد الأقصى لعدد رموز المنشور" - description: "الحد الأقصى لعدد الرموز التي سيتم فحصها باستخدام نظام فرز نموذج اللغة الكبير (LLM)" - stop_sequences: - label: "تسلسلات الإيقاف" - description: "أرشد النموذج إلى إيقاف إنشاء الرموز عند الوصول إلى إحدى هذه القيم" - search_for_text: - label: "البحث عن نص" - description: "إذا ظهر النص التالي في رد نموذج اللغة الكبير (LLM)، فطبِّق هذه الإجراءات" - category: - label: "الفئة" - description: "الفئة المراد تطبيقها على الموضوع" - tags: - label: "الوسوم" - description: "الوسوم المراد تطبيقها على الموضوع" - canned_reply: - label: "الرد" - description: "النص الأولي للرد الجاهز المراد نشره في الموضوع" - canned_reply_user: - label: "اسم المستخدم للرد" - description: "اسم المستخدم للمستخدم الذي سينشر الرد الجاهز" - hide_topic: - label: "إخفاء الموضوع" - description: "جعل الموضوع غير مرئي للعامة إذا تم بدؤه" - flag_type: - label: "نوع البلاغ" - description: "نوع البلاغ الذي سيتم تطبيقه على المنشور (منشور عشوائي أو لمجرد التصعيد للمراجعة)" - flag_post: - label: "الإبلاغ عن المنشور" - description: "يبلغ عن المنشور (سواءً كان عشوائيًا أو للمراجعة)" - include_personal_messages: - label: "تضمين الرسائل الشخصية" - description: "افحص الرسائل الشخصية وافرزها أيضًا" - model: - label: "النموذج" - description: "نموذج اللغة المُستخدَم في الفرز" - temperature: - label: "درجة الحرارة" - discourse_ai: - title: "الذكاء الاصطناعي" - features: - back: "الرجوع" - disabled: "(متوقفة)" - groups: "مجموعتان:" - no_persona: "غير معيَّن" - no_groups: "لا يوجد" - edit: "تعديل" - expand_list: - zero: "(%{count} أكثر)" - one: "(%{count} أكثر)" - two: "(%{count} أكثر)" - few: "(%{count} أكثر)" - many: "(%{count} أكثر)" - other: "(%{count} أكثر)" - collapse_list: "(عرض أقل)" - filters: - all: "الكل" - reset: "إعادة التعيين" - search: - name: "البحث" - embeddings: - name: "التضمينات" - ai_helper: - name: "المساعد" - proofread: تدقيق النص لغويًا - explain: "الشرح" - smart_dates: "التواريخ الذكية" - markdown_tables: "إنشاء جدول Markdown" - custom_prompt: "رسالة مطالبة مخصَّصة" - spam: - name: "عشوائي" - description: "يحدِّد السلوك العشوائي المُحتمَل باستخدام نموذج اللغة الكبير المُحدَّد، ويضع علامة عليه ليقوم مشرفو الموقع بفحصه في قائمة المراجعة" - modals: - select_option: "حدِّد خيارًا..." - spam: - short_title: "عشوائي" - title: "تكوين التعامل مع السلوك العشوائي" - select_llm: "تحديد نموذج اللغة الكبير" - custom_instructions: "التعليمات المُخصَّصة" - custom_instructions_help: "تعليمات مُخصَّصة خاصة بموقعك للمساعدة في توجيه الذكاء الاصطناعي في تحديد السلوك العشوائي؛ على سبيل المثال، \"كُن أكثر شراسة في مسح المنشورات غير المكتوبة باللغة العربية\"." - last_seven_days: "آخر 7 أيام" - scanned_count: "المنشورات التي تم فحصها" - false_positives: "تم الإبلاغ بشكلٍ غير صحيح" - false_negatives: "تم تفويت سلوك عشوائي" - spam_detected: "تم اكتشاف سلوك عشوائي" - custom_instructions_placeholder: "تعليمات خاصة بالموقع للذكاء الاصطناعي للمساعدة في تحديد السلوك العشوائي بشكلٍ أكثر دقة" - enable: "تفعيل" - spam_tip: "سيفحص نظام اكتشاف السلوك العشوائي بالذكاء الاصطناعي أول 3 منشورات لجميع المستخدمين الجُدد في الموضوعات العامة. وسيقوم بتمييزها للمراجعة وحظر المستخدمين إذا كان من المُرجَّح أن يكون سلوكهم عشوائيًا." - settings_saved: "تم حفظ الإعدادات" - spam_description: "يحدِّد السلوك العشوائي المُحتمَل باستخدام نموذج اللغة الكبير المُحدَّد، ويضع علامة عليه ليقوم مشرفو الموقع بفحصه في قائمة المراجعة" - no_llms: "لا توجد نماذج لغة كبيرة متوفرة" - test_button: "اختبار..." - save_button: "حفظ التغييرات" - test_modal: - title: "اختبار اكتشاف السلوك العشوائي" - post_url_label: "عنوان URL أو مُعرِّف المنشور" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 أو مُعرِّف المنشور" - result: "النتيجة" - scan_log: "سجل الفحص" - run: "تشغيل الاختبار" - spam: "عشوائي" - not_spam: "ليس سلوكًا عشوائيًا" - stat_tooltips: - incorrectly_flagged: "العناصر التي أبلغ روبوت الذكاء الاصطناعي عنها أنها عشوائية، ولم يوافق المشرفون على ذلك" - missed_spam: "العناصر التي أبلغ المجتمع عنها أنها عشوائية ولم يكتشفها روبوت الذكاء الاصطناعي، ووافق عليها المشرفون" - errors: - scan_not_admin: - message: "تحذير: لن يعمل فحص السلوك العشوائي بشكلٍ صحيح لأن حساب فحص البريد العشوائي ليس مسؤولًا" - action: "إصلاح" - resolved: "تم إصلاح الخطأ!" - usage: - short_title: "الاستخدام" - summary: "الملخص" - total_tokens: "إجمالي الرموز" - tokens_over_time: "الرموز بمرور الوقت" - features_breakdown: "الاستخدام لكل ميزة" - feature: "الميزة" - usage_count: "عدد مرات الاستخدام" - model: "النموذج" - models_breakdown: "الاستخدام لكل نموذج" - users_breakdown: "الاستخدام لكل مستخدم" - all_features: "كل الميزات" - all_models: "كل النماذج" - username: "اسم المستخدم" - total_requests: "إجمالي الطلبات" - request_tokens: "رموز الطلب" - response_tokens: "رموز الرد" - net_request_tokens: "صافي رموز الطلب" - cached_tokens: "الرموز المخزَّنة مؤقتًا" - cached_request_tokens: "رموز الطلب المُخزَّنة مؤقتًا" - no_users: "لم يتم العثور على بيانات استخدام المستخدم" - no_models: "لم يتم العثور على بيانات استخدام نموذجية" - no_features: "لم يتم العثور على بيانات استخدام الميزات" - subheader_description: "الرموز هي الوحدات الأساسية التي تستخدمها نماذج اللغة الكبيرة لفهم النصوص وتوليدها، وقد تؤثر بيانات الاستخدام على التكاليف" - stat_tooltips: - total_requests: "جميع الطلبات المُقدَّمة إلى نماذج اللغة الكبيرة من خلال Discourse" - total_tokens: "جميع الرموز المُستخدَمة عند إدخال رسالة مطالبة في نموذج لغة كبير" - request_tokens: "الرموز المستخدمة عندما يحاول نموذج اللغة الكبير فهم ما تقوله" - response_tokens: "الرموز المستخدمة عندما يستجيب نموذج اللغة الكبير لرسالة المطالبة الخاصة بك" - cached_tokens: "رموز الطلب التي تمت معالجتها مسبقًا والتي يعيد نموذج اللغة الكبير استخدامها لتحسين الأداء والتكلفة" - periods: - last_day: "آخر 24 ساعة" - last_week: "الأسبوع الماضي" - last_month: "الشهر الماضي" - custom: "مُخصَّص..." - ai_persona: - ai_tools: "الأدوات" - tool_strategies: - all: "التطبيق على كل الردود" - replies: - zero: "التطبيق على أول %{count} رد فقط" - one: "التطبيق على الرد الأول فقط" - two: "التطبيق على أول ردَّين (%{count}) فقط" - few: "التطبيق على أول %{count} ردود فقط" - many: "التطبيق على أول %{count} ردًا فقط" - other: "التطبيق على أول %{count} رد فقط" - back: "رجوع" - name: "الاسم" - edit: "تعديل" - export: "تصدير" - description: "الوصف" - no_llm_selected: "لم يتم تحديد نموذج لغة" - max_context_posts: "الحد الأقصى للمنشورات السياقية" - max_context_posts_help: "الحد الأقصى لعدد المنشورات التي سيتم استخدامها كسياق للذكاء الاصطناعي عند الرد على مستخدم. (اترك القيمة فارغة للإعداد الافتراضي)" - vision_enabled: دعم الرؤية - vision_enabled_help: إذا تم تفعيله، فسيحاول الذكاء الاصطناعي فهم الصور التي ينشرها المستخدمون في الموضوع، بناءً على النموذج المُستخدَم لدعم الرؤية. هذه الميزة مدعومة بأحدث النماذج من Anthropic وGoogle وOpenAI. - vision_max_pixels: الحجم المدعوم للصور - vision_max_pixel_sizes: - low: جودة منخفضة - الأرخص (256 × 256) - medium: جودة متوسطة (512 × 512) - high: جودة عالية - الأبطأ (1024 × 1024) - tool_details: عرض تفاصيل الأدوات - tool_details_help: ستعرض تفاصيل المستخدمين النهائيين بشأن الأدوات التي قام نموذج اللغة بتشغيلها. - mentionable: السماح بالإشارات - mentionable_help: إذا تم تفعيله، يمكن للمستخدمين في المجموعات المسموح بها الإشارة إلى هذا المستخدم في المنشورات، وسيرد الذكاء الاصطناعي باعتباره هذا الشخص. - user: المستخدم - create_user: إنشاء مستخدم - create_user_help: يمكنك اختياريًا إرفاق مستخدم بهذه الشخصية. إذا قمت بذلك، فسيستخدم الذكاء الاصطناعي هذا المستخدم للرد على الطلبات. - default_llm: نموذج اللغة الافتراضي - default_llm_help: نموذج اللغة الافتراضي الذي سيتم استخدامه لهذه الشخصية. مطلوب إذا كنت ترغب في الإشارة إلى شخصية في المنشورات العامة. - question_consolidator_llm: نموذج اللغة لتوحيد الأسئلة - question_consolidator_llm_help: نموذج اللغة الذي سيتم استخدامه لتوحيد الأسئلة، يمكنك اختيار نموذج أقل قوة للتوفير في التكاليف. - system_prompt: رسالة مطالبة النظام - forced_tool_strategy: استراتيجية الأداة الإجبارية - allow_chat_direct_messages: "السماح بالرسائل المباشرة في الدردشة" - allow_chat_direct_messages_help: "إذا تم التفعيل، يمكن للمستخدمين في المجموعات المسموح بها إرسال رسالة مباشرة إلى هذه الشخصية." - allow_chat_channel_mentions: "السماح بالإشارات في قناة الدردشة" - allow_chat_channel_mentions_help: "إذا تم التفعيل، يمكن للمستخدمين في المجموعات المسموح بها الإشارة إلى هذه الشخصية في قنوات الدردشة." - allow_personal_messages: "السماح بالرسائل الشخصية" - allow_personal_messages_help: "إذا تم التفعيل، يمكن للمستخدمين في المجموعات المسموح بها إرسال رسائل شخصية إلى هذه الشخصية." - allow_topic_mentions: "السماح بالإشارات في الموضوعات" - allow_topic_mentions_help: "إذا تم التفعيل، يمكن للمستخدمين في المجموعات المسموح بها الإشارة إلى هذه الشخصية في الموضوعات." - force_default_llm: "استخدام نموذج اللغة الافتراضي دائمًا" - save: "حفظ" - saved: "تم حفظ الشخصية" - enabled: "مفعَّلة؟" - tools: "الأدوات المفعَّلة" - forced_tools: "الأدوات الإجبارية" - allowed_groups: "المجموعات المسموح بها" - confirm_delete: "هل تريد بالتأكيد حذف هذه الشخصية؟" - new: "شخصية جديدة" - no_personas: "لم تُنشئ أي شخصيات بعد" - title: "الشخصيات" - short_title: "الشخصيات" - delete: "حذف" - temperature: "درجة الحرارة" - temperature_help: "درجة الحرارة التي سيتم استخدامها في نموذج اللغة الكبير (LLM)، يزداد الإبداع بزيادة القيمة (اترك القيمة فارغة لاستخدام النموذج الافتراضي، عادةً ما تكون قيمة بين 0.0 و2.0)" - top_p: "Top P" - top_p_help: "Top P التي سيتم استخدامها في نموذج اللغة الكبير (LLM)، تزداد العشوائية بزيادة القيمة (اترك القيمة فارغة لاستخدام النموذج الافتراضي، عادةً ما تكون قيمة بين 0.0 و1.0)" - priority: "الأولوية" - priority_help: "يتم عرض الشخصيات ذات الأولوية للمستخدمين في أعلى قائمة الشخصيات. إذا كانت الأولوية لعدة أشخاص، فسيتم فرزهم أبجديًا." - tool_options: "خيارات الأداة" - rag_conversation_chunks: "البحث في أجزاء المحادثة" - rag_conversation_chunks_help: "عدد الأجزاء التي سيتم استخدامها لإي عمليات البحث ضمن نموذج RAG. يزداد مقدار السياق الذي يمكن للذكاء الاصطناعي استخدامه بزيادة القيمة." - persona_description: "تُعد الشخصيات ميزة قوية تتيح لك تخصيص سلوك محرك الذكاء الاصطناعي في منتدى Discourse الخاص بك. إنها تعمل بمثابة \"رسالة نظام\" توجِّه ردود الذكاء الاصطناعي وتفاعلاته، مما يساعد في إنشاء تجربة مستخدم أكثر تخصيصًا وتفاعليةً." - response_format: - open_modal: "تعديل" - modal: - key_title: "المفتاح" - filters: - reset: "إعادة التعيين" - rag: - options: - rag_chunk_tokens: "تحميل أجزاء الرموز" - rag_chunk_tokens_help: "عدد الرموز المميزة التي سيتم استخدامها لكل جزء في نموذج RAG. يزداد مقدار السياق الذي يمكن للذكاء الاصطناعي استخدامه بزيادة القيمة. (سيؤدي التغيير إلى إعادة فهرسة جميع التحميلات)" - rag_chunk_overlap_tokens: "تحميل رموز تداخل الأجزاء" - rag_chunk_overlap_tokens_help: "عدد الرموز المميزة التي ستتداخل بين الأجزاء في نموذج RAG. (سيؤدي التغيير إلى إعادة فهرسة جميع التحميلات)" - show_indexing_options: "إظهار خيارات التحميل" - hide_indexing_options: "إخفاء خيارات التحميل" - uploads: - title: "التحميلات" - button: "إضافة الملفات" - filter: "تصفية التحميلات" - indexed: "تمت الفهرسة" - indexing: "جارٍ الفهرسة" - uploaded: "جاهزة للفهرسة" - uploading: "جارٍ التحميل..." - remove: "إزالة التحميل" - tools: - back: "رجوع" - short_title: "الأدوات" - export: "تصدير" - no_tools: "لم تُنشئ أي أدوات بعد" - name: "الاسم" - new: "أداة جديدة" - description: "الوصف" - description_help: "وصف واضح لغرض الأداة بالنسبة إلى نموذج اللغة" - subheader_description: "تعمل الأدوات على توسيع قدرات روبوتات الذكاء الاصطناعي باستخدام دوال JavaScript يحدِّدها المستخدم." - summary: "الملخص" - summary_help: "ملخص الغرض من الأدوات لعرضه للمستخدمين النهائيين" - script: "البرنامج النصي" - parameters: "المعلمات" - save: "حفظ" - remove_parameter: "إزالة" - parameter_required: "مطلوب" - parameter_enum: "Enum" - parameter_name: "اسم المعلمة" - parameter_description: "وصف المعلمة" - enum_value: "قيمة Enum" - add_enum_value: "إضافة قيمة enum" - edit: "تعديل" - test: "تشغيل الاختبار" - delete: "حذف" - saved: "تم حفظ الأداة" - confirm_delete: "هل تريد بالتأكيد حذف هذه الأداة؟" - test_modal: - title: "اختبار أداة الذكاء الاصطناعي" - run: "تشغيل الاختبار" - result: "نتيجة الاختبار" - llms: - short_title: "نماذج اللغة الكبيرة" - no_llms: "لا توجد نماذج لغة كبيرة بعد" - new: "نموذج جديد" - display_name: "الاسم" - name: "معرِّف النموذج" - provider: "مقدِّم الخدمة" - tokenizer: "أداة الترميز" - url: "عنوان URL لخدمة استضافة النموذج" - api_key: "مفتاح API لخدمة استضافة النموذج" - enabled_chat_bot: "السماح بمحدِّد روبوت الذكاء الاصطناعي" - vision_enabled: "دعم الرؤية" - ai_bot_user: "مستخدم روبوت الذكاء الاصطناعي" - save: "حفظ" - edit: "تعديل" - saved: "تم حفظ نموذج اللغة الكبير (LLM)" - back: "رجوع" - confirm_delete: هل تريد بالتأكيد حذف هذا النموذج؟ - delete: حذف - seeded_warning: "تم تكوين هذا النموذج مسبقًا على موقعك ولا يمكن تعديله." - quotas: - title: "حصص الاستخدام" - add_title: "إنشاء حصة جديدة" - group: "المجموعة" - max_tokens: "الحد الأقصى للرموز" - max_usages: "الحد الأقصى لمرات الاستخدام" - duration: "المدة" - confirm_delete: "هل تريد بالتأكيد حذف هذه الحصة؟" - add: "إضافة حصة" - durations: - hour: "ساعة واحدة" - six_hours: "6 ساعات" - day: "24 ساعة" - week: "7 أيام" - custom: "مُخصَّص..." - hours: "الساعات" - max_tokens_help: "الحد الأقصى لعدد الرموز (الكلمات والأحرف) التي يمكن لكل مستخدم في هذه المجموعة استخدامها خلال المدة المُحدَّدة. الرموز هي الوحدات التي تستخدمها نماذج الذكاء الاصطناعي لمعالجة النص - الرمز الواحد يساوي تقريبًا 4 أحرف أو 3/4 كلمة." - max_usages_help: "الحد الأقصى لعدد المرات التي يمكن لكل مستخدم في هذه المجموعة استخدام نموذج الذكاء الاصطناعي فيها خلال المدة المُحدَّدة. يتم تتبع هذه الحصة لكل مستخدم على حدة، ولا تتم مشاركتها عبر المجموعة." - usage: - ai_bot: "روبوت الذكاء الاصطناعي" - ai_helper: "المساعد" - ai_persona: "الشخصية (%{persona})" - ai_summarization: "تلخيص" - ai_embeddings_semantic_search: "البحث باستخدام الذكاء الاصطناعي" - ai_spam: "عشوائي" - in_use_warning: - zero: "هذا النموذج مُستخدَم حاليًا من قِبل %{settings}. إذا تم تكوينه بشكلٍ خاطئ، فلن تعمل الميزة كما هو متوقَّع. " - one: "هذا النموذج مُستخدَم حاليًا من قِبل %{settings}. إذا تم تكوينه بشكلٍ خاطئ، فلن تعمل الميزة كما هو متوقَّع." - two: "هذا النموذج مُستخدَم حاليًا من قِبل %{settings}. إذا تم تكوينه بشكلٍ خاطئ، فلن تعمل الميزتان كما هو متوقَّع. " - few: "هذا النموذج مُستخدَم حاليًا من قِبل %{settings}. إذا تم تكوينه بشكلٍ خاطئ، فلن تعمل الميزات كما هو متوقَّع. " - many: "هذا النموذج مُستخدَم حاليًا من قِبل %{settings}. إذا تم تكوينه بشكلٍ خاطئ، فلن تعمل الميزات كما هو متوقَّع. " - other: "هذا النموذج مُستخدَم حاليًا من قِبل %{settings}. إذا تم تكوينه بشكلٍ خاطئ، فلن تعمل الميزات كما هو متوقَّع. " - model_description: - none: "الإعدادات العامة التي تعمل مع معظم نماذج اللغة" - anthropic-claude-opus-4-0: "النموذج الأكثر ذكاءً لدى Anthropic" - anthropic-claude-3-5-haiku-latest: "سريع وفعَّال من حيث التكلفة" - google-gemini-2-5-flash: "خفيف وسريع وفعَّال من حيث التكلفة مع الاستدلال متعدد النماذج" - samba_nova-Meta-Llama-3-1-8B-Instruct: "نموذج متعدد اللغات خفيف وفعَّال" - samba_nova-Meta-Llama-3-3-70B-Instruct": "نموذج قوي متعدد الأغراض" - mistral-mistral-large-latest: "النموذج الأقوى لدى Mistral" - mistral-pixtral-large-latest: "النموذج القادر على الرؤية الأقوى لدى Mistral" - preseeded_model_description: "نموذج مفتوح المصدر تم تكوينه مسبقًا باستخدام %{model}" - configured: - title: "تم تكوين نماذج اللغة الكبيرة (LLM)" - preconfigured_llms: "حدِّد نموذج اللغة الكبير الخاص بك" - preconfigured: - title_no_llms: "حدِّد قالبًا للبدء" - title: "لم يتم تكوين قوالب نماذج اللغة الكبيرة (LLM)" - description: "نماذج اللغة الكبيرة (LLM) عبارة عن أدوات ذكاء اصطناعي تم تحسينها لتنفيذ مهام مثل تلخيص المحتوى وإنشاء التقارير وأتمتة تفاعلات العملاء وتسهيل إدارة المنتديات وتقديم الرؤى" - fake: "التكوين اليدوي" - button: "إعداد" - next: - title: "التالي" - tests: - title: "تشغيل الاختبار" - running: "جارٍ تشغيل الاختبار..." - success: "تم بنجاح!" - failure: "أرجعت محاولة التواصل مع النموذج هذا الخطأ: %{error}" - hints: - name: "إننا نقوم بتضمين هذا في استدعاء واجهة برمجة التطبيقات (API) لتحديد النموذج الذي سنستخدمه" - vision_enabled: "إذا تم تفعيله، فسيحاول الذكاء الاصطناعي فهم الصور. إنه يعتمد على النموذج المُستخدَم لدعم الرؤية. هذه الميزة مدعومة بأحدث النماذج من Anthropic وGoogle وOpenAI." - enabled_chat_bot: "إذا تم تفعيله، يمكن للمستخدمين تحديد هذا النموذج عند إنشاء الرسائل الخاصة باستخدام روبوت الذكاء الاصطناعي" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "مخصَّصة" - provider_fields: - access_key_id: "معرِّف مفتاح الوصول إلى AWS Bedrock" - region: "منطقة AWS Bedrock" - organization: "معرِّف مؤسسة OpenAI الاختياري" - disable_system_prompt: "إيقاف رسالة النظام في رسائل المطالبة" - enable_native_tool: "تفعيل دعم الأدوات الأصلية" - disable_native_tools: "إيقاف دعم الأدوات الأصلية (استخدام أدوات تعتمد على XML)" - provider_order: "ترتيب مقدِّمي الخدمة (قائمة مفصولة بفاصلة)" - provider_quantizations: "ترتيب كميات مقدِّمي الخدمة (قائمة مفصولة بفاصلة. على سبيل المثال: fp16,fp8)" - disable_streaming: "إيقاف التوليد التدريجي للنصوص (تحويل التوليد التدريجي للنصوص إلى التوليد الكامل للنصوص)" - related_topics: - title: "الموضوعات ذات الصلة" - pill: "موضوعات ذات صلة" - ai_helper: - title: "اقتراح تغييرات باستخدام الذكاء الاصطناعي" - description: "اختر أحد الخيارات أدناه، وسيقترح عليك الذكاء الاصطناعي نسخة جديدة من النص." - selection_hint: "تلميح: يمكنك أيضًا تحديد جزء من النص قبل فتح أداة المساعدة لإعادة كتابة ذلك الجزء فقط." - suggest: "الاقتراح باستخدام الذكاء الاصطناعي" - suggest_errors: - too_many_tags: - zero: "يمكن أن يكون لديك %{count} وسم فقط كحد أقصى" - one: "يمكن أن يكون لديك وسم واحد (%{count}) فقط كحد أقصى" - two: "يمكن أن يكون لديك وسمان (%{count}) فقط كحد أقصى" - few: "يمكن أن يكون لديك %{count} وسوم فقط كحد أقصى" - many: "يمكن أن يكون لديك %{count} وسمًا فقط كحد أقصى" - other: "يمكن أن يكون لديك %{count} وسم فقط كحد أقصى" - no_suggestions: "لا توجد اقتراحات متوفرة" - missing_content: "يُرجى إدخال بعض المحتوى لإنشاء الاقتراحات." - context_menu: - trigger: "اسأل الذكاء الاصطناعي" - loading: "الذكاء الاصطناعي قيد إنشاء المحتوى" - cancel: "إلغاء" - confirm: "تأكيد" - discard: "تجاهل" - changes: "التعديلات المقترحة" - custom_prompt: - title: "رسالة مطالبة مخصَّصة" - placeholder: "أدخل رسالة مطالبة مخصَّصة..." - submit: "إرسال رسالة مطالبة" - translate_prompt: "الترجمة إلى %{language}" - post_options_menu: - trigger: "اسأل الذكاء الاصطناعي" - title: "اسأل الذكاء الاصطناعي" - loading: "الذكاء الاصطناعي قيد إنشاء المحتوى" - close: "إغلاق" - copy: "نسخ" - copied: "تم النسخ!" - cancel: "إلغاء" - insert_footnote: "إضافة حاشية سفلية" - footnote_disabled: "تم إيقاف الإدراج التلقائي، انقر على زر النسخ وقم بتعديله يدويًا" - footnote_credits: "شرح بواسطة الذكاء الاصطناعي" - fast_edit: - suggest_button: "اقتراح تعديل" - thumbnail_suggestions: - title: "الصور المصغَّرة المقترحة" - select: "تحديد" - selected: "محدَّدة" - image_caption: - button_label: "التسمية التوضيحية بالذكاء الاصطناعي" - generating: "جارٍ إنشاء تسمية توضيحية.." - credits: "تم إنشاء التسمية التوضيحية بالذكاء الاصطناعي" - save_caption: "حفظ" - automatic_caption_setting: "تفعيل التسمية التوضيحية التلقائية" - automatic_caption_loading: "جارٍ إنشاء تسميات توضيحية للصور..." - automatic_caption_dialog: - prompt: "بحتوي هذا المنشور على صور دون تسميات توضيحية. هل ترغب في تمكين التسميات التوضيحية التلقائية عند تحميل الصور؟ (يمكن تغيير هذا في تفضيلاتك لاحقًا)" - confirm: "تفعيل" - cancel: "لا تسألني مرة أخرى" - no_content_error: "ينبغي إضافة المحتوى أولًا لتنفيذ إجراءات الذكاء الاصطناعي عليه" - reviewables: - model_used: "النموذج المستخدم:" - accuracy: "الدقة:" - embeddings: - short_title: "التضمينات" - new: "تضمين جديد" - back: "رجوع" - save: "حفظ" - saved: "تم حفظ تكوين التضمين" - delete: "حذف" - confirm_delete: هل تريد بالتأكيد إزالة تكوين التضمين هذا؟ - empty: "لم تقم بإعداد التضمينات بعد" - presets: "حدِّد إعدادًا مسبقًا..." - configure_manually: "التكوين يدويًا" - edit: "تعديل" - seeded_warning: "تم تكوين هذا مسبقًا على موقعك ولا يمكن تعديله." - tests: - title: "تشغيل الاختبار" - running: "جارٍ تشغيل الاختبار..." - success: "تم بنجاح!" - failure: "أدَّت محاولة إنشاء تضمين إلى: %{error}" - hints: - dimensions_warning: "لا يمكن تغييرها بعد حفظها." - matryoshka_dimensions: "تحدِّد حجم التضمينات المتداخلة المُستخدَمة في التمثيل التسلسلي أو متعدد الطبقات للبيانات، وذلك على غرار طريقة ملاءمة الدمى المتداخلة (الماتريوشكا) مع بعضها البعض." - sequence_length: "الحد الأقصى لعدد الرموز التي يمكن معالجتها في الوقت نفسه عند إنشاء التضمينات أو معالجة استعلام." - distance_function: "تحدِّد كيفية حساب التشابه بين التضمينات، باستخدام إما مسافة جيب التمام (قياس الزاوية بين المتجهات) أو حاصل الضرب الداخلي السلبي (قياس تداخل قيم المتجهات)." - display_name: "الاسم" - provider: "مقدِّم الخدمة" - url: "عنوان URL لخدمة التضمينات" - api_key: "مفتاح API لخدمة التضمينات" - tokenizer: "أداة الترميز" - dimensions: "أبعاد التضمين" - max_sequence_length: "طول التسلسل" - embed_prompt: "أمر التضمين" - search_prompt: "أمر البحث" - matryoshka_dimensions: "الأبعاد المتداخلة" - distance_function: "دالة المسافة" - distance_functions: - "<#>": "حاصل الضرب الداخلي السلبي" - <=>: "مسافة جيب التمام" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "مخصَّصة" - provider_fields: - model_name: "اسم النموذج" - semantic_search: "الموضوعات (دلالية)" - semantic_search_loading: "جارٍ البحث عن المزيد من النتائج باستخدام الذكاء الاصطناعي" - semantic_search_results: - toggle: "جارٍ عرض %{count} من النتائج التي تم العثور عليها باستخدام الذكاء الاصطناعي" - toggle_hidden: "جارٍ إخفاء %{count} من النتائج التي تم العثور عليها باستخدام الذكاء الاصطناعي" - none: "عذرًا، لم يعثر بحث الذكاء الاصطناعي لدينا على أي موضوعات مطابقة" - new: "اضغط على \"بحث\" لبدء البحث عن نتائج جديدة باستخدام الذكاء الاصطناعي" - unavailable: "نتائج الذكاء الاصطناعي غير متوفرة" - semantic_search_tooltips: - results_explanation: "عند التفعيل، سيتم إضافة نتائج بحث الذكاء الاصطناعي الإضافية أدناه." - invalid_sort: "يجب فرز نتائج البحث حسب الصلة لعرض نتائج الذكاء الاصطناعي" - semantic_search_unavailable_tooltip: "يجب فرز نتائج البحث حسب الصلة لعرض نتائج الذكاء الاصطناعي" - ai_generated_result: "تم العثور على نتيجة بحث باستخدام الذكاء الاصطناعي" - quick_search: - suffix: "في كل الموضوعات والمنشورات بالذكاء الاصطناعي" - ai_artifact: - expand_view_label: "توسيع العرض" - collapse_view_label: "الخروج من وضع ملء الشاشة (ESC أو زر الرجوع)" - click_to_run_label: "تشغيل المنتج الثانوي" - ai_bot: - llm: "النموذج" - pm_warning: "تتم مراقبة رسائل روبوت دردشة الذكاء الاصطناعي بانتظام من قِبل المشرفين." - cancel_streaming: "إيقاف الرد" - default_pm_prefix: "[رسالة خاصة دون عنوان من روبوت ذكاء اصطناعي]" - shortcut_title: "بدء رسالة خاصة باستخدام روبوت ذكاء اصطناعي" - share: "نسخ محادثة الذكاء الاصطناعي" - conversation_shared: "تم نسخ المحادثة" - debug_ai: "عرض طلب الذكاء الاصطناعي الأولي والرد" - debug_ai_modal: - title: "عرض تفاعل الذكاء الاصطناعي" - copy_request: "طلب النسخة" - copy_response: "رد النسخة" - request_tokens: "رموز الطلب:" - response_tokens: "رموز الرد:" - request: "طلب" - response: "الرد" - next_log: "التالي" - previous_log: "السابق" - share_full_topic_modal: - title: "مشاركة المحادثة بشكلٍ علني" - share: "مشاركة ونسخ الرابط" - update: "تحديث ونسخ الرابط" - delete: "حذف المشاركة" - share_ai_conversation: - name: "مشاركة محادثة الذكاء الاصطناعي" - title: "مشاركة هذه المحادثة بشكلٍ علني" - invite_ai_conversation: - button: "دعوة" - ai_label: "الذكاء الاصطناعي" - ai_title: "المحادثة مع الذكاء الاصطناعي" - share_modal: - title: "نسخ محادثة الذكاء الاصطناعي" - copy: "نسخ" - context: "التفاعلات المراد مشاركتها:" - share_tip: "بدلًا من ذلك، يمكنك مشاركة المحادثة بأكملها" - bot_names: - fake: "Fake Test Bot" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "اليوم" - last_7_days: "آخر 7 أيام" - last_30_days: "آخر 30 يومًا" - sentiments: - dashboard: - title: "المشاعر" - sentiment_analysis: - filter_types: - all: "الكل" - positive: "إيجابية" - neutral: "محايدة" - negative: "سلبية" - group_types: - category: "الفئة" - tag: "الوسم" - table: - sentiment: "المشاعر" - total_count: "الإجمالي" - summarization: - chat: - title: "تلخيص الرسائل" - description: "حدِّد خيارًا أدناه لتلخيص المحادثة المُرسَلة خلال الإطار الزمني المطلوب." - summarize: "تلخيص" - since: - zero: "آخر %{count} ساعة" - one: "آخر ساعة" - two: "آخر ساعتين (%{count})" - few: "آخر %{count} ساعات" - many: "آخر %{count} ساعة" - other: "آخر %{count} ساعة" - topic: - title: "ملخص الموضوع" - close: "إغلاق لوحة الملخص" - topic_list_layout: - button: - compact: "مضغوط" - expanded: "موسَّع" - expanded_description: "مع ملخصات الذكاء الاصطناعي" - discobot_discoveries: - regular_results: "الموضوعات" - collapse: "طي" - tooltip: - actions: - disable: "إيقاف" - review: - types: - reviewable_ai_post: - title: "منشور تم الإبلاغ عنه بواسطة الذكاء الاصطناعي" - reviewable_ai_chat_message: - title: "رسالة دردشة تم الإبلاغ عنها بواسطة الذكاء الاصطناعي" diff --git a/config/locales/client.be.yml b/config/locales/client.be.yml deleted file mode 100644 index b443082a..00000000 --- a/config/locales/client.be.yml +++ /dev/null @@ -1,144 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -be: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "сартаваць па" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Номер тэмы" - title: - label: "Загаловак" - categories: - label: "катэгорыі" - tags: - label: "тэгі" - llm_triage: - fields: - category: - label: "катэгорыя" - tags: - label: "тэгі" - canned_reply: - label: "Адказаць" - discourse_ai: - features: - back: "Назад" - disabled: "(адключана)" - groups: "групы:" - edit: "Рэдагаваць" - filters: - all: "Усё" - reset: "скінуць" - search: - name: "Пошук" - spam: - name: "Спам" - spam: - short_title: "Спам" - enable: "Уключыць" - test_modal: - spam: "Спам" - usage: - summary: "вынік" - username: "Імя карыстальніка" - total_requests: "Усяго запытаў" - ai_persona: - back: "Назад" - name: "імя" - edit: "рэдагаваць" - export: "экспарт" - description: "Апісанне" - user: карыстальнік - save: "Захаваць" - delete: "выдаляць" - response_format: - open_modal: "рэдагаваць" - filters: - reset: "скінуць" - rag: - uploads: - title: "Загрузкі" - uploading: "Запампоўванне ..." - tools: - back: "Назад" - export: "экспарт" - name: "імя" - description: "Апісанне" - summary: "вынік" - save: "Захаваць" - remove_parameter: "выдаліць" - parameter_required: "абавязковыя" - edit: "Рэдагаваць" - delete: "Выдаліць" - llms: - display_name: "імя" - save: "Захаваць" - edit: "рэдагаваць" - back: "Назад" - delete: выдаляць - quotas: - group: "група" - usage: - ai_spam: "Спам" - next: - title: "Далей" - tests: - success: "поспех!" - providers: - google: "Google" - ai_helper: - context_menu: - cancel: "адмяніць" - post_options_menu: - close: "зачыніць" - copy: "капіяваць" - cancel: "адмяніць" - image_caption: - save_caption: "захаваць" - automatic_caption_dialog: - confirm: "Уключыць" - embeddings: - back: "Назад" - save: "захаваць" - delete: "Выдаліць" - edit: "Рэдагаваць" - tests: - success: "поспех!" - display_name: "Імя" - providers: - google: "Google" - ai_bot: - debug_ai_modal: - request: "запыт" - next_log: "Далей" - share_modal: - copy: "капіяваць" - conversations: - today: "сёння" - sentiments: - sentiment_analysis: - filter_types: - all: "Усё" - neutral: "нейтральны" - group_types: - category: "катэгорыя" - table: - total_count: "агульны" - discobot_discoveries: - regular_results: "тэмы" - tooltip: - actions: - disable: "Адключыць" diff --git a/config/locales/client.bg.yml b/config/locales/client.bg.yml deleted file mode 100644 index 4e3b03eb..00000000 --- a/config/locales/client.bg.yml +++ /dev/null @@ -1,175 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -bg: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Сортирай по" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Тема ID " - title: - label: "Заглавие" - categories: - label: "Категории" - tags: - label: "Тагове" - llm_triage: - fields: - category: - label: "Категория" - tags: - label: "Тагове" - canned_reply: - label: "Отговорете" - discourse_ai: - features: - back: "Назад" - disabled: "(деактивирани)" - groups: "Групи:" - no_persona: "Не е зададено" - no_groups: "Без" - edit: "Редактирай" - expand_list: - one: "(още %{count})" - other: "(още %{count})" - collapse_list: "(покажи по-малко)" - filters: - all: "Всички" - reset: "Нулиране" - search: - name: "Търсене" - spam: - name: "Спам" - modals: - select_option: "Изберете опция..." - spam: - short_title: "Спам" - last_seven_days: "Последните 7 дни" - enable: "Позволи" - test_modal: - spam: "Спам" - usage: - summary: "Сумарно" - username: "Потребителско име" - total_requests: "Общо заявки" - periods: - last_day: "Последните 24 часа" - custom: "Ръчно задаване..." - ai_persona: - back: "Назад" - name: "Име" - edit: "Редактирай" - export: "Експорт " - description: "Описание" - user: Потребител - save: "Запази " - enabled: "Да е включен?" - delete: "Изтрий" - response_format: - open_modal: "Редактирай" - modal: - key_title: "Ключ" - filters: - reset: "Нулиране" - rag: - uploads: - uploading: "Качва се..." - tools: - back: "Назад" - export: "Експорт " - name: "Име" - description: "Описание" - summary: "Сумарно" - save: "Запази " - remove_parameter: "Премахване" - parameter_required: "Задъжителни" - edit: "Редактирай" - delete: "Изтрий" - llms: - display_name: "Име" - save: "Запази " - edit: "Редактирай" - back: "Назад" - delete: Изтрий - quotas: - group: "Група" - max_usages: "Максимум използвания" - duration: "Продължителност" - durations: - hour: "1 час" - six_hours: "6 часа" - day: "24 часа" - custom: "Ръчно задаване..." - hours: "часа" - usage: - ai_summarization: "Обобщаване" - ai_spam: "Спам" - next: - title: "Напред" - providers: - google: "Google" - fake: "По избор" - ai_helper: - context_menu: - cancel: "Прекрати" - discard: "Отхвърляне" - post_options_menu: - close: "Затвори" - copy: "Копирай" - copied: "Копирано!" - cancel: "Прекрати" - image_caption: - save_caption: "Запази " - automatic_caption_dialog: - confirm: "Позволи" - embeddings: - back: "Назад" - save: "Запази " - delete: "Изтрий" - edit: "Редактирай" - display_name: "Име " - providers: - google: "Google" - fake: "По избор" - ai_bot: - debug_ai_modal: - request: "Заявка" - next_log: "Напред" - previous_log: "Предишни" - invite_ai_conversation: - button: "Покана" - share_modal: - copy: "Копирай" - conversations: - today: "Днес" - last_7_days: "Последните 7 дни" - last_30_days: "Последните 30 дни" - sentiments: - sentiment_analysis: - filter_types: - all: "Всички" - group_types: - category: "Категория" - table: - total_count: "Общо" - summarization: - chat: - summarize: "Обобщаване" - discobot_discoveries: - regular_results: "Теми" - collapse: "Намали" - tooltip: - actions: - disable: "Деактивиране" diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml deleted file mode 100644 index 7e752147..00000000 --- a/config/locales/client.bs_BA.yml +++ /dev/null @@ -1,163 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -bs_BA: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Sortiraj po" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID teme" - title: - label: "Naslov" - categories: - label: "Kategorije" - tags: - label: "Oznake" - llm_triage: - fields: - category: - label: "Kategorija" - tags: - label: "Oznake" - canned_reply: - label: "Odgovori" - discourse_ai: - features: - back: "Prethodno" - disabled: "(neomogućen)" - groups: "Groups:" - no_groups: "Ništa" - edit: "Edit" - expand_list: - one: "(još %{count})" - few: "(još %{count})" - other: "(još %{count})" - filters: - all: "All" - reset: "Resetovati" - search: - name: "Pretraži" - spam: - name: "Spam" - modals: - select_option: "Odaberi opciju..." - spam: - short_title: "Spam" - enable: "Omogući" - test_modal: - spam: "Spam" - usage: - summary: "Sažetak" - username: "Nadimak" - ai_persona: - back: "Prethodno" - name: "Ime" - edit: "Edit" - export: "Izvoz" - description: "Opis" - user: User - save: "Save" - enabled: "Omogućen?" - allowed_groups: "Dozvoljene grupe" - delete: "Delete" - response_format: - open_modal: "Edit" - modal: - key_title: "Ključ" - filters: - reset: "Resetovati" - rag: - uploads: - title: "Uploads" - uploading: "Učitavanje..." - tools: - back: "Prethodno" - export: "Izvoz" - name: "Ime" - description: "Opis" - summary: "Sažetak" - save: "Save" - remove_parameter: "Ukloni" - parameter_required: "Required" - edit: "Edit" - delete: "Delete" - llms: - display_name: "Ime" - save: "Save" - edit: "Edit" - back: "Prethodno" - delete: Delete - quotas: - group: "Grupa" - duration: "Trajanje" - usage: - ai_spam: "Spam" - next: - title: "Iduće" - tests: - success: "Uspjeh!" - providers: - google: "Google" - fake: "Custom" - ai_helper: - context_menu: - cancel: "Odustani" - discard: "Odbaci" - post_options_menu: - close: "Zatvori" - copy: "Copy" - copied: "Kopirano!" - cancel: "Odustani" - image_caption: - save_caption: "Save" - automatic_caption_dialog: - confirm: "Omogući" - embeddings: - back: "Prethodno" - save: "Save" - delete: "Delete" - edit: "Edit" - tests: - success: "Uspjeh!" - display_name: "Ime" - providers: - google: "Google" - fake: "Custom" - ai_bot: - debug_ai_modal: - request: "Zatraži" - response: "Odgovor" - next_log: "Iduće" - previous_log: "Previous" - invite_ai_conversation: - button: "Invite" - share_modal: - copy: "Copy" - conversations: - today: "Today" - sentiments: - sentiment_analysis: - filter_types: - all: "All" - group_types: - category: "Kategorija" - table: - total_count: "Suma" - discobot_discoveries: - regular_results: "Topics" - collapse: "Spusti" - tooltip: - actions: - disable: "Onemogući" diff --git a/config/locales/client.ca.yml b/config/locales/client.ca.yml deleted file mode 100644 index 709d52ac..00000000 --- a/config/locales/client.ca.yml +++ /dev/null @@ -1,170 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ca: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Ordena per" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID de tema" - title: - label: "Títol" - categories: - label: "Categories" - tags: - label: "Etiquetes" - llm_triage: - fields: - category: - label: "Categoria" - tags: - label: "Etiquetes" - canned_reply: - label: "Respon" - discourse_ai: - features: - back: "Enrere" - disabled: "(desactivat)" - groups: "Grups:" - no_groups: "Cap" - edit: "Edita" - expand_list: - one: "(%{count} més)" - other: "(%{count} més)" - filters: - all: "Tot" - reset: "Restableix" - search: - name: "Cerca" - spam: - name: "Brossa" - modals: - select_option: "Trieu una opció..." - spam: - short_title: "Brossa" - last_seven_days: "Els darrers 7 dies" - enable: "Activa" - test_modal: - spam: "Brossa" - usage: - summary: "Resum" - username: "Nom d'usuari " - total_requests: "Total de peticions" - periods: - last_day: "Últimes 24 hores" - ai_persona: - back: "Enrere" - name: "Nom" - edit: "Edita" - export: "Exporta" - description: "Descripció" - user: Usuari - save: "Desa" - enabled: "Activat?" - delete: "Suprimeix" - response_format: - open_modal: "Edita" - modal: - key_title: "Clau" - filters: - reset: "Restableix" - rag: - uploads: - title: "Càrregues" - uploading: "Carregant..." - tools: - back: "Enrere" - export: "Exporta" - name: "Nom" - description: "Descripció" - summary: "Resum" - save: "Desa" - remove_parameter: "Elimina" - parameter_required: "Necessari" - edit: "Edita" - delete: "Suprimeix" - llms: - display_name: "Nom" - save: "Desa" - edit: "Edita" - back: "Enrere" - delete: Suprimeix - quotas: - group: "Grup" - duration: "Duració" - hours: "hores" - usage: - ai_spam: "Brossa" - next: - title: "Següent" - tests: - success: "Èxit!" - providers: - google: "Google" - fake: "Personalitzat" - ai_helper: - context_menu: - cancel: "Cancel·la" - confirm: "Confirma" - discard: "Descarta" - post_options_menu: - close: "Tanca" - copy: "Còpia" - copied: "Copiat!" - cancel: "Cancel·la" - image_caption: - save_caption: "Desa" - automatic_caption_dialog: - confirm: "Activa" - embeddings: - back: "Enrere" - save: "Desa" - delete: "Suprimeix" - edit: "Edita" - tests: - success: "Èxit!" - display_name: "Nom" - providers: - google: "Google" - fake: "Personalitzat" - ai_bot: - debug_ai_modal: - request: "Sol·licita" - response: "Reacció" - next_log: "Següent" - previous_log: "Previ" - invite_ai_conversation: - button: "Convida" - share_modal: - copy: "Còpia" - conversations: - today: "Avui" - last_7_days: "Els darrers 7 dies" - last_30_days: "Els darrers 30 dies" - sentiments: - sentiment_analysis: - filter_types: - all: "Tot" - neutral: "Neutre" - group_types: - category: "Categoria" - table: - total_count: "Total" - discobot_discoveries: - regular_results: "Temes" - collapse: "Redueix" - tooltip: - actions: - disable: "Desactiva" diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml deleted file mode 100644 index e85012ec..00000000 --- a/config/locales/client.cs.yml +++ /dev/null @@ -1,339 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -cs: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Řadit podle" - tag: - label: "Štítek" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID tématu" - title: - label: "Nadpis" - categories: - label: "Kategorie" - tags: - label: "Tagy" - llm_triage: - fields: - category: - label: "Kategorie" - tags: - label: "Tagy" - canned_reply: - label: "Odpověď" - discourse_ai: - features: - back: "Zpět" - disabled: "(vypnuto)" - groups: "Skupiny:" - no_persona: "Nenastaveno" - no_groups: "Žádná" - edit: "Upravit" - expand_list: - one: "(%{count} další)" - few: "(%{count} další)" - many: "(%{count} další)" - other: "(%{count} další)" - collapse_list: "(zobrazit méně)" - filters: - all: "Vše" - reset: "obnovit výchozí" - search: - name: "Vyhledávání" - ai_helper: - proofread: Korektura textu - explain: "Vysvětlit" - smart_dates: "Chytré datumy" - markdown_tables: "Generovat Markdown tabulku" - custom_prompt: "Vlastní pokyn" - spam: - name: "Spam" - modals: - select_option: "Zvolit možnost..." - spam: - short_title: "Spam" - last_seven_days: "Posledních 7 dní" - enable: "Zapnout" - test_modal: - spam: "Spam" - usage: - summary: "Souhrn" - username: "Uživatelské jméno" - total_requests: "Celkem požadavků" - periods: - last_day: "Posledních 24 hodin" - custom: "Vlastní…" - ai_persona: - back: "Zpět" - name: "Jméno" - edit: "Upravit" - export: "Export" - description: "Popis" - user: Uživatel - save: "Uložit" - enabled: "Zapnuto?" - delete: "Smazat" - response_format: - open_modal: "Upravit" - modal: - key_title: "Klíč" - filters: - reset: "obnovit výchozí" - rag: - uploads: - title: "Nahrané soubory" - button: "Přidat soubory" - uploading: "Nahrává se..." - tools: - back: "Zpět" - export: "Export" - name: "Jméno" - description: "Popis" - summary: "Souhrn" - save: "Uložit" - remove_parameter: "Smazat" - parameter_required: "Nezbytnosti" - edit: "Upravit" - delete: "Smazat" - llms: - display_name: "Jméno" - save: "Uložit" - edit: "Upravit" - back: "Zpět" - delete: Smazat - quotas: - group: "Skupina" - max_usages: "Max. použití" - duration: "Doba trvání" - durations: - hour: "1 hodina" - six_hours: "6 hodin" - day: "24 hodin" - week: "7 dní" - custom: "Vlastní…" - hours: "hodiny" - usage: - ai_summarization: "Shrnout" - ai_spam: "Spam" - next: - title: "Další" - tests: - success: "Úspěch!" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Přizpůsobené" - related_topics: - title: "Související témata" - pill: "Související" - ai_helper: - title: "Navrhněte změny pomocí AI" - description: "Vyberte jednu z níže uvedených možností a umělá inteligence vám navrhne novou verzi textu." - selection_hint: "Tip: Před otevřením pomocníka můžete také vybrat část textu a přepsat pouze tuto část." - suggest: "Navrhnout s AI" - suggest_errors: - too_many_tags: - one: "Můžete mít max. %{count} štítek" - few: "Můžete mít max. %{count} štítky" - many: "Můžete mít max. %{count} štítků" - other: "Můžete mít max. %{count} štítků" - no_suggestions: "Nejsou k dispozici žádné návrhy" - missing_content: "Chcete-li generovat návrhy, zadejte nějaký obsah." - context_menu: - trigger: "Zeptejte se AI" - loading: "Umělá inteligence generuje" - cancel: "Zrušit" - confirm: "Potvrdit" - discard: "Zrušit" - changes: "Navrhované úpravy" - custom_prompt: - title: "Vlastní pokyn" - placeholder: "Napište vlastní pokyn..." - submit: "Odeslat pokyn" - translate_prompt: "Přeložit do %{language}" - post_options_menu: - trigger: "Zeptejte se AI" - title: "Zeptejte se AI" - loading: "Umělá inteligence generuje" - close: "Zavřít" - copy: "Kopírovat" - copied: "Zkopírováno!" - cancel: "Zrušit" - insert_footnote: "Přidat poznámku pod čarou" - footnote_disabled: "Automatické vkládání je zakázáno, klikněte na tlačítko Kopírovat a upravte jej ručně." - footnote_credits: "Vysvětlení pomocí AI" - fast_edit: - suggest_button: "Navrhnout úpravu" - thumbnail_suggestions: - title: "Navrhované miniatury" - select: "Vybrat" - selected: "Vybrané" - image_caption: - button_label: "Popisek pomocí AI" - generating: "Generování popisku..." - credits: "Popisek od AI" - save_caption: "Uložit" - automatic_caption_setting: "Povolit automatické popisky" - automatic_caption_loading: "Popisuji obrázky..." - automatic_caption_dialog: - prompt: "Tento příspěvek obsahuje obrázky bez popisků. Chcete povolit automatické popisky při nahrávání obrázků? (To lze později změnit ve vašich nastaveních)" - confirm: "Zapnout" - cancel: "Již se neptat" - no_content_error: "Nejprve přidejte obsah, abyste na něm mohli provádět akce AI" - embeddings: - back: "Zpět" - save: "Uložit" - delete: "Smazat" - edit: "Upravit" - tests: - title: "Spustit test" - success: "Úspěch!" - display_name: "Název" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - CDCK: "CDCK" - fake: "Přizpůsobené" - semantic_search_results: - toggle: "Zobrazeno %{count} výsledků nalezených pomocí AI" - toggle_hidden: "Skryto %{count} výsledků nalezených pomocí AI" - none: "Je nám líto, naše vyhledávání AI nenalezlo žádná odpovídající témata" - new: "Stisknutím tlačítka „hledat“ začněte hledat nové výsledky pomocí AI" - unavailable: "Výsledky AI nejsou k dispozici" - semantic_search_tooltips: - results_explanation: "Pokud je tato funkce povolena, budou níže přidány další výsledky vyhledávání pomocí umělé inteligence." - ai_generated_result: "Výsledek hledání nalezen pomocí AI" - quick_search: - suffix: "ve všech tématech a příspěvcích s AI" - ai_artifact: - collapse_view_label: "Ukončení celé obrazovky (ESC nebo tlačítko Zpět)" - ai_bot: - pm_warning: "Zprávy chatbotů s umělou inteligencí jsou pravidelně monitorovány moderátory." - cancel_streaming: "Zastavit odpověď" - default_pm_prefix: "[Nepojmenovaná SZ od AI bota]" - shortcut_title: "Začněte SZ s AI botem" - share: "Kopírovat konverzaci s AI" - conversation_shared: "Konverzace zkopírována" - debug_ai: "Zobrazit nezpracovaný požadavek a odpověď umělé inteligence" - sidebar_empty: "Zde se zobrazí historie konverzace s botem." - debug_ai_modal: - title: "Zobrazit interakci s umělou inteligencí" - copy_request: "Kopírovat požadavek" - copy_response: "Kopírovat odpověď" - request_tokens: "Tokeny požadavku:" - response_tokens: "Tokeny odpovědi:" - request: "Požadavek" - response: "Odpověď" - next_log: "Další" - previous_log: "Předchozí" - share_full_topic_modal: - title: "Veřejně sdílet konverzaci" - share: "Sdílet a kopírovat odkaz" - update: "Aktualizovat a zkopírovat odkaz" - delete: "Odstranit sdílení" - share_ai_conversation: - name: "Sdílet konverzaci s AI" - title: "Sdílejte veřejně tuto konverzaci s umělou inteligencí" - invite_ai_conversation: - button: "Pozvat" - title: "Pozvat do konverzace s AI" - ai_title: "Konverzace s AI" - share_modal: - title: "Kopírovat konverzaci s AI" - copy: "Kopírovat" - context: "Interakce ke sdílení:" - share_tip: "Můžete také sdílet celou konverzaci." - bot_names: - fake: "Falešný testovací bot" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonet" - claude-3-haiku: "Haiku Claude 3" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - header: "S čím mohu pomoci?" - submit: "Odeslat otázku" - disclaimer: "Generativní umělá inteligence může dělat chyby. Ověřte si důležité informace." - placeholder: "Zeptejte se..." - new: "Nová otázka" - min_input_length_message: - one: "Zpráva musí mít 1 nebo více znaků" - few: "Zpráva musí mít %{count} nebo více znaků" - many: "Zpráva musí mít %{count} nebo více znaků" - other: "Zpráva musí mít %{count} nebo více znaků" - messages_sidebar_title: "Konverzace" - today: "Dnes" - last_7_days: "Posledních 7 dní" - last_30_days: "Posledních 30 dní" - upload_files: "Nahrát soubory" - sentiments: - dashboard: - title: "Sentiment" - sentiment_analysis: - filter_types: - all: "Vše" - group_types: - category: "Kategorie" - tag: "Štítek" - table: - sentiment: "Sentiment" - total_count: "Celkem" - summarization: - chat: - title: "Shrnout zprávy" - description: "Výběrem níže uvedené možnosti shrnete konverzaci odeslanou v požadovaném časovém období." - summarize: "Shrnout" - since: - one: "Poslední hodina" - few: "Poslední %{count} hodiny" - many: "Posledních %{count} hodin" - other: "Posledních %{count} hodin" - topic: - title: "Souhrn tématu" - close: "Zavřít panel souhrnu" - topic_list_layout: - button: - compact: "Kompaktní" - expanded: "Rozšířený" - expanded_description: "se souhrny AI" - discobot_discoveries: - regular_results: "Témata" - collapse: "Sbalit" - tooltip: - actions: - disable: "Vypnout" diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml deleted file mode 100644 index 96facb17..00000000 --- a/config/locales/client.da.yml +++ /dev/null @@ -1,186 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -da: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Sorter efter" - tag: - label: "Mærke" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Emne ID" - title: - label: "Titel" - categories: - label: "Kategorier" - tags: - label: "Mærker" - llm_triage: - fields: - category: - label: "Kategori" - tags: - label: "Mærker" - canned_reply: - label: "Svar" - discourse_ai: - features: - back: "Tilbage" - disabled: "(deaktiveret)" - groups: "Grupper:" - no_groups: "Ingen" - edit: "Rediger" - expand_list: - one: "(%{count} mere)" - other: "(%{count} mere)" - collapse_list: "(vis mindre)" - filters: - all: "Alle" - reset: "Nulstil" - search: - name: "Søg" - spam: - name: "Spam" - modals: - select_option: "Vælg en indstilling..." - spam: - short_title: "Spam" - last_seven_days: "Seneste 7 dage" - enable: "Aktiver" - test_modal: - result: "Resultat" - spam: "Spam" - usage: - summary: "Statistik" - username: "Brugernavn" - total_requests: "Total antal anmodninger" - periods: - last_day: "Seneste 24 timer" - custom: "Tilpasset..." - ai_persona: - back: "Tilbage" - name: "Navn" - edit: "Rediger" - export: "Eksporter" - description: "Beskrivelse" - user: Bruger - save: "Gem" - enabled: "Aktiveret?" - allowed_groups: "Tilladte grupper" - delete: "Slet" - response_format: - open_modal: "Rediger" - modal: - key_title: "Nøgle" - filters: - reset: "Nulstil" - rag: - uploads: - title: "Overførsler" - uploading: "Overfører…" - tools: - back: "Tilbage" - export: "Eksporter" - name: "Navn" - description: "Beskrivelse" - summary: "Statistik" - save: "Gem" - remove_parameter: "Fjern" - parameter_required: "Obligatoriske" - edit: "Rediger" - delete: "Slet" - llms: - display_name: "Navn" - save: "Gem" - edit: "Rediger" - back: "Tilbage" - delete: Slet - quotas: - group: "Gruppe" - max_usages: "Maks. anvendelser" - duration: "Varighed" - durations: - hour: "1 time" - six_hours: "6 timer" - day: "24 timer" - week: "7 dage" - custom: "Tilpasset..." - hours: "timer" - usage: - ai_spam: "Spam" - next: - title: "Næste" - tests: - success: "Succes!" - providers: - google: "Google" - fake: "Tilpasset" - ai_helper: - context_menu: - cancel: "Annuller" - confirm: "Bekræft" - discard: "Kassér" - post_options_menu: - close: "Luk" - copy: "Kopier" - copied: "Kopieret!" - cancel: "Annuller" - thumbnail_suggestions: - select: "Vælg" - image_caption: - save_caption: "Gem" - automatic_caption_dialog: - confirm: "Aktiver" - embeddings: - back: "Tilbage" - save: "Gem" - delete: "Slet" - edit: "Rediger" - tests: - success: "Succes!" - display_name: "Navn" - providers: - google: "Google" - fake: "Tilpasset" - ai_bot: - debug_ai_modal: - request: "Anmod om medlemskab" - response: "Svar" - next_log: "Næste" - previous_log: "Forrige" - invite_ai_conversation: - button: "Invitér" - share_modal: - copy: "Kopier" - conversations: - today: "I dag" - last_7_days: "Seneste 7 dage" - last_30_days: "Seneste 30 dage" - sentiments: - sentiment_analysis: - filter_types: - all: "Alle" - neutral: "Neutral" - group_types: - category: "Kategori" - tag: "Mærke" - table: - total_count: "Total" - discobot_discoveries: - regular_results: "Emner" - collapse: "Fold sammen" - tooltip: - actions: - disable: "Deaktiver" diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml deleted file mode 100644 index 48ef1221..00000000 --- a/config/locales/client.de.yml +++ /dev/null @@ -1,896 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -de: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Ermöglicht KI-Suche" - stream_completion: "Ermöglicht das Streamen von KI-Persona-Vervollständigungen" - update_personas: "Ermöglicht die Aktualisierung von KI-Personas" - site_settings: - categories: - discourse_ai: "Discourse-KI" - dashboard: - emotion: - title: "Emotion" - description: "Die Tabelle listet die Anzahl der Beiträge auf, die mit einer bestimmten Emotion klassifiziert wurden. Klassifiziert mit dem Modell „SamLowe/roberta-base-go_emotions“." - reports: - filters: - group_by: - label: "Gruppieren nach" - sort_by: - label: "Sortieren nach" - tag: - label: "Schlagwort" - logs: - staff_actions: - actions: - create_ai_llm_model: "LLM-Modell erstellen" - update_ai_llm_model: "LLM-Modell aktualisieren" - delete_ai_llm_model: "LLM-Modell löschen" - create_ai_persona: "KI-Persona erstellen" - update_ai_persona: "KI-Persona aktualisieren" - delete_ai_persona: "KI-Persona löschen" - create_ai_tool: "KI-Tool erstellen" - update_ai_tool: "KI-Tool aktualisieren" - delete_ai_tool: "KI-Tool löschen" - create_ai_embedding: "KI-Einbettung erstellen" - update_ai_embedding: "KI-Einbettung aktualisieren" - delete_ai_embedding: "KI-Einbettung löschen" - update_ai_spam_settings: "KI-Spam-Einstellungen aktualisieren" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Absender" - description: "Der Benutzer, der den Bericht senden wird" - receivers: - label: "Empfänger" - description: "Die Benutzer, die den Bericht erhalten sollen (an E-Mail-Adressen wird eine E-Mail verschickt, Benutzernamen erhalten eine PN)" - topic_id: - label: "Themen-ID" - description: "Die Themen-ID für die Veröffentlichung des Berichts" - title: - label: "Titel" - description: "Der Titel des Berichts" - days: - label: "Tage" - description: "Die Zeitspanne des Berichts" - offset: - label: "Offset" - description: "Wenn du den Bericht zu Testzwecken in der Vergangenheit ausführen möchtest, kannst du den Bericht mit einem Offset zu einem früheren Zeitpunkt starten." - instructions: - label: "Anweisungen" - description: "Die Anweisungen für das große Sprachmodell" - sample_size: - label: "Stichprobengröße" - description: "Die Anzahl der Beiträge, die für den Bericht ausgewählt werden sollen" - tokens_per_post: - label: "Token pro Beitrag" - description: "Die Anzahl der pro Beitrag zu verwendenden LLM-Token" - model: - label: "Modell" - description: "LLM zur Berichtserstellung" - categories: - label: "Kategorien" - description: "Themen nur nach diesen Kategorien filtern" - tags: - label: "Schlagwörter" - description: "Themen nur nach diesen Schlagwörtern filtern" - exclude_tags: - label: "Schlagwörter ausschließen" - description: "Themen mit diesen Schlagwörtern ausschließen" - exclude_categories: - label: "Kategorien ausschließen" - description: "Themen mit diesen Kategorien ausschließen" - allow_secure_categories: - label: "Sichere Kategorien zulassen" - description: "Erlaube, dass der Bericht für Themen in sicheren Kategorien erstellt wird" - suppress_notifications: - label: "Benachrichtigungen unterdrücken" - description: "Unterdrücke Benachrichtigungen, die der Bericht durch die Umwandlung in Inhalt erzeugen kann. Dadurch werden Erwähnungen und interne Links umgewandelt." - debug_mode: - label: "Debug-Modus" - description: "Aktiviere den Debug-Modus, um die Roheingabe und -ausgabe des LLM anzuzeigen" - priority_group: - label: "Prioritätsgruppe" - description: "Inhalte aus dieser Gruppe im Bericht hervorheben" - temperature: - label: "Temperatur" - description: "Temperatur, die für das LLM verwendet werden soll. Erhöhen, um die Zufälligkeit zu erhöhen (leer lassen, um das Standardmodell zu verwenden)" - top_p: - label: "Top P" - description: "Top P für die LLM, erhöhen, um die Zufälligkeit zu erhöhen (leer lassen, um das Standardmodell zu verwenden)" - llm_tool_triage: - fields: - model: - label: "Modell" - description: "Das für die Sichtung verwendete Standard-Sprachmodell" - tool: - label: "Tool" - description: "Tool für die Sichtung (das Tool darf keine Parameter definiert haben)" - llm_persona_triage: - fields: - persona: - label: "Persona" - description: "KI-Persona, die für die Sichtung verwendet werden soll (Standard-LLM und -Benutzer müssen eingestellt sein)" - whisper: - label: "Als Flüstern antworten" - description: "Ob die Antwort der Persona ein Flüstern sein soll" - silent_mode: - label: "Stiller Modus" - description: "Im stillen Modus empfängt die Persona den Inhalt, schreibt aber nichts in das Forum - nützlich bei der Sichtung mit Tools" - llm_triage: - fields: - system_prompt: - label: "System-Eingabeaufforderung" - description: "Die Eingabeaufforderung, die für die Triage verwendet wird. Achte darauf, dass sie mit einem einzigen Wort antwortet, das du zum Auslösen der Aktion verwenden kannst" - max_post_tokens: - label: "Max. Beitrags-Token" - description: "Die maximale Anzahl von Token, die mit LLM-Triage gescannt werden" - stop_sequences: - label: "Sequenzen stoppen" - description: "Weise das Modell an, die Token-Generierung anzuhalten, wenn einer dieser Werte erreicht wird" - search_for_text: - label: "Suche nach Text" - description: "Wenn der folgende Text in der LLM-Antwort erscheint, wende diese Maßnahmen an" - category: - label: "Kategorie" - description: "Kategorie, die auf das Thema anzuwenden ist" - tags: - label: "Schlagwörter" - description: "Schlagwörter, die auf das Thema anzuwenden sind" - canned_reply: - label: "Antworten" - description: "Rohtext der vorgefertigten Antwort auf einen Beitrag zum Thema" - canned_reply_user: - label: "Antwortender Benutzer" - description: "Benutzername des Benutzers, der die vorgefertigte Antwort posten soll" - hide_topic: - label: "Thema ausblenden" - description: "Thema nicht für die Öffentlichkeit sichtbar machen, wenn ausgelöst" - flag_type: - label: "Meldungs-Typ" - description: "Art der Meldung, die auf den Beitrag angewendet werden soll (Spam oder einfach zur Überprüfung anzeigen)" - flag_post: - label: "Beitrag melden" - description: "Meldet den Beitrag (entweder als Spam oder zur Überprüfung)" - include_personal_messages: - label: "Persönliche Nachrichten einbeziehen" - description: "Auch persönliche Nachrichten scannen und sortieren" - whisper: - label: "Als Flüstern antworten" - description: "Ob die Antwort der KI ein Flüstern sein soll" - reply_persona: - label: "Antwort Persona" - description: "KI-Persona, die für Antworten verwendet werden soll (muss Standard-LLM haben), wird gegenüber vorgefertigten Antworten bevorzugt" - model: - label: "Modell" - description: "Für die Triage verwendetes Sprachmodell" - temperature: - label: "Temperatur" - description: "Temperatur, die für das LLM verwendet werden soll. Erhöhen, um die Zufälligkeit zu erhöhen (leer lassen, um das Standardmodell zu verwenden)" - discourse_ai: - title: "KI" - features: - short_title: "Funktionen" - description: "Dies sind die KI-Funktionen, die den Besuchern deiner Website zur Verfügung stehen. Sie können so konfiguriert werden, dass sie bestimmte Personas und LLM verwenden, und der Zugriff kann mithilfe von Gruppen gesteuert werden." - back: "Zurück" - disabled: "(deaktiviert)" - persona: - one: "Persona:" - other: "Personas:" - groups: "Gruppen:" - llm: - one: "LLM:" - other: "LLM:" - no_llm: "Kein LLM ausgewählt" - no_persona: "Nicht festgelegt" - no_groups: "Keiner" - edit: "Bearbeiten" - expand_list: - one: "(%{count} weitere)" - other: "(%{count} weitere)" - collapse_list: "(weniger anzeigen)" - bot: - bot: "Chatbot" - name: "Bot" - description: "Ein Chatbot, der Fragen beantworten und Benutzern in persönlichen Nachrichten, im Forum und im Chat helfen kann" - nav: - configured: "Konfiguriert" - unconfigured: "Nicht konfiguriert" - filters: - all: "Gesamt" - text: "Suche nach Funktionen, Personas, LLMs oder Gruppen..." - no_results: "Es wurden keine Funktionen gefunden, die deinen Filtern entsprechen." - reset: "Zurücksetzen" - summarization: - name: "Zusammenfassungen" - description: "Stellt eine Schaltfläche für Zusammenfassungen zur Verfügung, mit der Besucher Themen zusammenfassen können" - topic_summaries: "Themenzusammenfassungen" - gists: "Kurze Zusammenfassungen der in Themenliste" - search: - name: "Suche" - description: "Verbessert das Sucherlebnis durch KI-generierte Antworten auf Suchanfragen" - discoveries: "Entdeckungen" - embeddings: - name: "Einbettungen" - description: "Ermöglicht Funktionen wie „Verwandte Themen“ und „KI-Suche“ durch die Erstellung semantischer Textdarstellungen" - hyde: "HyDE" - discord: - name: "Discord-Integration" - description: "Fügt die Möglichkeit hinzu, Discord-Kanäle zu durchsuchen" - search: "Discord-Suche" - inference: - name: "Abgeleitete Konzepte" - description: "Ordnet Themen und Beiträge in Interessensgebiete / Labels." - generate_concepts: "Konzepterkennung" - match_concepts: "Passende Konzepte" - deduplicate_concepts: "Deduplizierung von Konzepten" - ai_helper: - name: "Helfer" - description: "Unterstützt Benutzer bei der Interaktion in der Community, z. B. beim Erstellen von Themen, Verfassen von Beiträgen und Lesen von Inhalten" - proofread: Text korrekturlesen - title_suggestions: "Titel vorschlagen" - explain: "Erklären" - illustrate_post: "Beitrag illustrieren" - smart_dates: "Intelligente Termine" - translate: "Übersetzen" - markdown_tables: "Markdown-Tabelle generieren" - custom_prompt: "Benutzerdefinierte Eingabeaufforderung" - image_caption: "Bildbeschriftungen" - translator: "Übersetzer" - translation: - name: "Übersetzen" - description: "Übersetzt Inhalte in unterstützte Sprachen" - locale_detector: "Sprachdetektor" - post_raw_translator: "Übersetzer für die Roheingabe des Beitrags" - topic_title_translator: "Thementitel-Übersetzer" - short_text_translator: "Kurztext-Übersetzer" - spam: - name: "Spam" - description: "Identifiziert potenziellen Spam mithilfe des ausgewählten LLMs und meldet ihn den Moderatoren der Website in der Warteschlange zur Überprüfung." - inspect_posts: "Beiträge prüfen" - modals: - select_option: "Wähle eine Option aus..." - layout: - table: "Tabelle" - card: "Karte" - spam: - short_title: "Spam" - title: "Spam-Behandlung konfigurieren" - select_llm: "LLM auswählen" - select_persona: "Persona auswählen" - custom_instructions: "Benutzerdefinierte Anweisungen" - custom_instructions_help: "Benutzerdefinierte Anweisungen speziell für deine Website, die der KI beim Identifizieren von Spam helfen, z. B. „Gehe beim Scannen von Posts, die nicht in englischer Sprache sind, aggressiver vor.“" - last_seven_days: "Letzte 7 Tage" - scanned_count: "Gescannte Beiträge" - false_positives: "Falsch gemeldet" - false_negatives: "Spam übersehen" - spam_detected: "Spam erkannt" - custom_instructions_placeholder: "Websitespezifische Anweisungen für die KI zur genaueren Identifizierung von Spam" - enable: "Aktivieren" - spam_tip: "Die KI-Spamerkennung scannt die ersten 3 Beiträge aller neuen Benutzer zu öffentlichen Themen. Sie meldet sie zur Überprüfung und blockiert Benutzer, wenn es sich wahrscheinlich um Spam handelt." - settings_saved: "Einstellungen gespeichert" - spam_description: "Identifiziert potenziellen Spam mithilfe des ausgewählten LLMs und meldet ihn den Moderatoren der Website in der Warteschlange zur Überprüfung." - no_llms: "Keine LLMs verfügbar" - test_button: "Test …" - save_button: "Änderungen speichern" - test_modal: - title: "Testen der Spam-Erkennung" - post_url_label: "Beitrags-URL oder -ID" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 oder Beitrags-ID" - result: "Ergebnis" - scan_log: "Scan-Protokoll" - run: "Test ausführen" - spam: "Spam" - not_spam: "Kein Spam" - stat_tooltips: - incorrectly_flagged: "Inhalte, die der KI-Bot als Spam gemeldet hat und bei denen die Moderatoren anderer Meinung waren" - missed_spam: "Von der Community als Spam gemeldete und von Moderatoren als solche bestätigte Inhalte, die vom KI-Bot nicht erkannt wurden" - errors: - scan_not_admin: - message: "Warnung: Spam-Scannen wird nicht korrekt funktionieren, da das Spam-Scan-Konto kein Administrator ist" - action: "Beheben" - resolved: "Der Fehler wurde behoben!" - usage: - short_title: "Verwendung" - summary: "Zusammenfassung" - total_tokens: "Token gesamt" - tokens_over_time: "Token im Laufe der Zeit" - features_breakdown: "Nutzung pro Funktion" - feature: "Funktion" - usage_count: "Nutzungsanzahl" - model: "Modell" - models_breakdown: "Nutzung pro Modell" - users_breakdown: "Nutzung pro Benutzer" - all_features: "Alle Funktionen" - all_models: "Alle Modelle" - username: "Benutzername" - total_requests: "Gesamte Anfragen" - request_tokens: "Anfrage-Token" - response_tokens: "Antwort-Token" - net_request_tokens: "Token für Netzanfragen" - cached_tokens: "Zwischengespeicherte Token" - cached_request_tokens: "Zwischengespeicherte Anfragetoken" - total_spending: "Geschätzte Kosten" - no_users: "Keine Benutzernutzungsdaten gefunden" - no_models: "Keine Modellnutzungsdaten gefunden" - no_features: "Keine Funktionsnutzungsdaten gefunden" - subheader_description: "Token sind die Basiseinheiten, die LLM zum Verstehen und Generieren von Text verwenden. Nutzungsdaten können sich auf die Kosten auswirken" - stat_tooltips: - total_requests: "Alle Anfragen an LLMs über Discourse" - total_tokens: "Alle Token, die bei der Abfrage einer LLM verwendet werden" - request_tokens: "Tokens, die verwendet werden, wenn das LLM versucht zu verstehen, was du sagst" - response_tokens: "Token, die verwendet werden, wenn die LLM auf deine Aufforderung antwortet" - cached_tokens: "Zuvor verarbeitete Anfragetoken, die das LLM wiederverwendet, um Leistung und Kosten zu optimieren" - total_spending: "Kumulierte Kosten aller von den LLM verwendeten Token auf Grundlage der spezifischen Kostenmetriken, die zu den LLM-Konfigurationseinstellungen hinzugefügt wurden" - periods: - last_day: "Letzte 24 Stunden" - last_week: "Letzte Woche" - last_month: "Letzter Monat" - custom: "Benutzerdefiniert …" - ai_persona: - ai_tools: "Tools" - tool_strategies: - all: "Auf alle Antworten anwenden" - replies: - one: "Nur auf die erste Antwort anwenden" - other: "Auf die ersten %{count} Antworten anwenden" - back: "Zurück" - name: "Name" - edit: "Bearbeiten" - export: "Exportieren" - import: "Importieren" - import_error_conflict: "Beim Importieren von %{name} wurde ein Konflikt erkannt. Möchtest du die vorhandene Persona aktualisieren?" - overwrite: "Überschreiben" - description: "Beschreibung" - no_llm_selected: "Kein Sprachmodell ausgewählt" - use_parent_llm: "Verwende das Personas Sprachmodell" - max_context_posts: "Max. Kontext-Beiträge" - max_context_posts_help: "Die maximale Anzahl von Beiträgen, die die KI als Kontext für die Antwort auf einen Nutzer verwenden soll (leer für Standardwert)." - vision_enabled: Sehen aktiviert - vision_enabled_help: Wenn diese Funktion aktiviert ist, versucht die KI, die Bilder zu verstehen, die Nutzer im Thema posten (abhängig davon, ob das verwendete Modell Sehen unterstützt). Unterstützt von den neuesten Modellen von Anthropic, Google und OpenAI. - vision_max_pixels: Unterstützte Bildgröße - vision_max_pixel_sizes: - low: Niedrige Qualität – am günstigsten (256 x 256) - medium: Mittlere Qualität (512 x 512) - high: Hohe Qualität – am langsamsten (1024 x 1024) - tool_details: Tool-Details anzeigen - tool_details_help: Zeigt den Endnutzern Details darüber, welche Tools das Sprachmodell ausgelöst hat. - mentionable: Erwähnungen zulassen - mentionable_help: Wenn diese Funktion aktiviert ist, können Nutzer in erlaubten Gruppen diesen Nutzer in Beiträgen erwähnen und die KI wird als diese Persona antworten. - user: Nutzer - create_user: Benutzer erstellen - create_user_help: Du kannst dieser Persona optional einen Nutzer zuordnen. Wenn du das tust, wird die KI diesen Nutzer verwenden, um auf Anfragen zu antworten. - default_llm: Standard-Sprachmodell - default_llm_help: Das Standard-Sprachmodell, das für diese Persona verwendet werden soll. Erforderlich, wenn du die Persona in öffentlichen Beiträgen erwähnen möchtest. - question_consolidator_llm: Sprachmodell für Fragenkonsolidierer - question_consolidator_llm_help: Das Sprachmodell, das für den Fragenkonsolidierer verwendet werden soll. Du kannst ein weniger leistungsfähiges Modell wählen, um Kosten zu sparen. - system_prompt: System-Eingabeaufforderung - forced_tool_strategy: Erzwungene Tool-Strategie - allow_chat_direct_messages: "Chat-Direktnachrichten zulassen" - allow_chat_direct_messages_help: "Wenn aktiviert, können Benutzer in zulässigen Gruppen Direktnachrichten an diese Person senden." - allow_chat_channel_mentions: "Chat-Kanal-Erwähnungen zulassen" - allow_chat_channel_mentions_help: "Wenn aktiviert, können Benutzer in zulässigen Gruppen diese Persona in Chatkanälen erwähnen." - allow_personal_messages: "Persönliche Nachrichten zulassen" - allow_personal_messages_help: "Wenn aktiviert, können Benutzer in zulässigen Gruppen persönliche Nachrichten an diese Persona senden." - allow_topic_mentions: "Themenerwähnungen zulassen" - allow_topic_mentions_help: "Wenn aktiviert, können Benutzer in zulässigen Gruppen diese Persona in Themen erwähnen." - force_default_llm: "Immer das Standard-Sprachmodell verwenden" - save: "Speichern" - saved: "Persona gespeichert" - enabled: "Aktiviert?" - tools: "Aktivierte Tools" - forced_tools: "Erzwungene Tools" - allowed_groups: "Zulässige Gruppen" - confirm_delete: "Bist du sicher, dass du diese Persona löschen willst?" - new: "Neue Persona" - no_personas: "Du hast noch keine Personas erstellt" - title: "Personas" - short_title: "Personas" - delete: "Löschen" - temperature: "Temperatur" - temperature_help: "Temperatur, die für das LLM verwendet werden soll. Erhöhen, um die Kreativität zu steigern (leer lassen, um den Standardwert des Modells zu verwenden, im Allgemeinen ein Wert zwischen 0,0 und 2,0)" - top_p: "Top P" - top_p_help: "Top P für das LLM. Erhöhen, um die Zufälligkeit zu steigern (leer lassen, um den Standardwert des Modells zu verwenden, in der Regel ein Wert zwischen 0,0 und 1,0)" - priority: "Priorität" - priority_help: "Personas mit Priorität werden den Benutzern am Anfang der Persona-Liste angezeigt. Wenn mehrere Personas Priorität haben, werden sie alphabetisch sortiert." - tool_options: "Tool-Optionen" - rag_conversation_chunks: "Unterhaltungs-Chunks durchsuchen" - rag_conversation_chunks_help: "Die Anzahl der Chunks, die für die RAG-Modell-Suche verwendet werden. Erhöhen, um die Menge des Kontexts zu steigern, den die KI verwenden kann." - persona_description: "Personas sind eine leistungsstarke Funktion, mit der du das Verhalten der KI-Engine in deinem Discourse-Forum anpassen kannst. Sie fungieren als „Systemnachricht“, welche die Antworten und Interaktionen der KI steuert und dazu beiträgt, ein persönlicheres und ansprechenderes Erlebnis für Benutzer zu schaffen." - response_format: - title: "JSON-Antwortformat" - no_format: "Kein JSON-Format angegeben" - open_modal: "Bearbeiten" - modal: - root_title: "Antwortstruktur" - key_title: "Schlüssel" - examples: - title: Beispiele - examples_help: Simuliere frühere Interaktionen mit dem LLM und erde es, um bessere Ergebnisse zu erzielen. - new: Neues Beispiel - remove: Beispiel löschen - collapsable_title: "Beispiel #%{number}" - user: "Nachricht des Benutzers" - model: "Antwort des Modells" - list: - enabled: "KI-Bot?" - ai_bot: - title: "KI-Bot-Optionen" - save_first: "Weitere KI-Bot-Optionen werden verfügbar, sobald du die Persona gespeichert hast." - filters: - text: "Finde eine Persona" - reset: "Zurücksetzen" - no_results: "Es wurden keine Personas gefunden, die deinen Filtern entsprechen." - all_features: "Jede Funktion" - features_list: - one: "Funktion:" - other: "Funktionen:" - llms_list: "LLM:" - rag: - title: "RAG" - options: - rag_chunk_tokens: "Chunk-Token hochladen" - rag_chunk_tokens_help: "Die Anzahl der Token, die für jeden Chunk im RAG-Modell verwendet werden. Erhöhen, um die Menge des Kontexts zu steigern, den die KI verwenden kann. (Eine Änderung führt zu einer Neuindizierung aller Uploads.)" - rag_chunk_overlap_tokens: "Chunk-Überlappungs-Token hochladen" - rag_chunk_overlap_tokens_help: "Die Anzahl der Token, die sich zwischen den Chunks im RAG-Modell überlappen sollen. (Eine Änderung führt zu einer Neuindizierung aller Uploads.)" - rag_llm_model: "Indizierungssprachmodell" - rag_llm_model_help: "Das für die OCR bei der Indizierung von PDFs und Bildern verwendete Sprachmodell" - show_indexing_options: "Upload-Optionen anzeigen" - hide_indexing_options: "Upload-Optionen ausblenden" - uploads: - title: "Uploads" - description: "PDF (.pdf), Klartext (.txt) oder Markdown (.md)" - description_with_images: "Klartext (.txt), Markdown (.md), PDF (.pdf) oder Bild (.png, .jpeg)" - button: "Dateien hinzufügen" - filter: "Uploads filtern" - indexed: "Indiziert" - indexing: "Indizierung" - uploaded: "Bereit zur Indizierung" - uploading: "Wird hochgeladen …" - remove: "Upload entfernen" - tools: - back: "Zurück" - short_title: "Tools" - export: "Exportieren" - import: "Importieren" - import_error_conflict: "Tool existiert bereits, möchtest du es aktualisieren?" - overwrite: "Überschreiben" - no_tools: "Du hast noch keine Tools erstellt" - name: "Name" - name_help: "Der Name wird in der Discourse-Benutzeroberfläche angezeigt und ist die Kurzkennung, die du verwendest, um das Tool in verschiedenen Einstellungen zu finden. Er sollte eindeutig sein (er ist erforderlich)." - new: "Neues Tool" - tool_name: "Name des Tools" - tool_name_help: "Der Werkzeugname wird dem großen Sprachmodell präsentiert. Es ist nicht eindeutig, aber es ist von Person zu Person unterschiedlich. (Persona wird beim Speichern validiert)" - description: "Beschreibung" - description_help: "Eine klare Beschreibung des Zwecks des Tools für das Sprachmodell" - subheader_description: "Tools erweitern die Fähigkeiten von KI-Bots mit benutzerdefinierten JavaScript-Funktionen." - summary: "Zusammenfassung" - summary_help: "Zusammenfassung des Zwecks der Tools, die den Endnutzern angezeigt werden soll" - script: "Skript" - parameters: "Parameter" - save: "Speichern" - parameter_type: "Parametertyp" - add_parameter: "Parameter hinzufügen" - remove_parameter: "Entfernen" - parameter_required: "Erforderlich" - parameter_enum: "Aufzählung" - parameter_name: "Parametername" - parameter_description: "Parameterbeschreibung" - enum_value: "Aufzählungswert" - add_enum_value: "Aufzählungswert hinzufügen" - edit: "Bearbeiten" - test: "Test ausführen" - delete: "Löschen" - saved: "Tool gespeichert" - confirm_delete: "Bist du sicher, dass du dieses Tool löschen willst?" - test_modal: - title: "KI-Tool testen" - run: "Test ausführen" - result: "Testergebnis" - llms: - short_title: "LLM" - no_llms: "Noch keine LLM" - new: "Neues Modell" - display_name: "Name" - name: "Modell-ID" - provider: "Anbieter" - tokenizer: "Tokenizer" - max_prompt_tokens: "Kontextfenster" - max_output_tokens: "Maximale Ausgabetoken" - url: "URL des Dienstes, der das Modell hostet" - api_key: "API-Schlüssel des Dienstes, der das Modell hostet" - enabled_chat_bot: "KI-Bot-Auswahl zulassen" - vision_enabled: "Sehen aktiviert" - ai_bot_user: "KI-Bot-Benutzer" - cost_input: "Eingabekosten" - cost_cached_input: "Eingabekosten für Zwischengespeichertes" - cost_output: "Ausgabekosten" - save: "Speichern" - edit: "Bearbeiten" - saved: "LLM-Modell gespeichert" - back: "Zurück" - confirm_delete: Bist du sicher, dass du dieses Modell löschen willst? - delete: Löschen - seeded_warning: "Dieses Modell ist auf deiner Website vorkonfiguriert und kann nicht bearbeitet werden." - quotas: - title: "Nutzungskontingente" - add_title: "Neues Kontingent erstellen" - group: "Gruppe" - max_tokens: "Max. Token" - max_usages: "Max. Verwendungen" - duration: "Dauer" - confirm_delete: "Bist du sicher, dass du dieses Kontingent löschen willst?" - add: "Kontingent hinzufügen" - durations: - hour: "1 Stunde" - six_hours: "6 Stunden" - day: "24 Stunden" - week: "7 Tage" - custom: "Benutzerdefiniert …" - hours: "Stunden" - max_tokens_help: "Maximale Anzahl von Token (Wörtern und Zeichen), die jeder Benutzer in dieser Gruppe innerhalb der angegebenen Dauer verwenden kann. Token sind die Einheiten, die von KI-Modellen zur Textverarbeitung verwendet werden – etwa 1 Token = 4 Zeichen oder 3/4 eines Wortes." - max_tokens_required: "Muss gesetzt werden, wenn die maximale Nutzung nicht festgelegt ist" - max_usages_help: "Die maximale Anzahl von Benutzern in dieser Gruppe, die das KI-Modell innerhalb der angegebenen Dauer nutzen können. Dieses Kontingent wird für jeden einzelnen Nutzer verfolgt und nicht für die ganze Gruppe." - max_usages_required: "Muss gesetzt werden, wenn die maximale Anzahl an Tokens nicht gesetzt ist" - usage: - ai_bot: "KI-Bot" - ai_helper: "Helfer" - ai_helper_image_caption: "Bildbeschriftungen" - ai_persona: "Persona (%{persona})" - ai_summarization: "Zusammenfassen" - ai_embeddings_semantic_search: "KI-Suche" - ai_spam: "Spam" - automation: "Automatisierung (%{persona})" - in_use_warning: - one: "Dieses Modell wird derzeit von %{settings} verwendet. Wenn es falsch konfiguriert ist, wird die Funktion nicht wie erwartet funktionieren." - other: "Dieses Modell wird derzeit verwendet von: %{settings}. Wenn es falsch konfiguriert ist, werden die Funktionen nicht wie erwartet funktionieren. " - model_description: - none: "Allgemeine Einstellungen, die für die meisten Sprachmodelle funktionieren" - anthropic-claude-opus-4-0: "Das intelligenteste Modell von Anthropic" - anthropic-claude-sonnet-4-0: "Optimales Gleichgewicht zwischen Geschwindigkeit und Kosten" - anthropic-claude-3-7-sonnet-latest: "Optimales Gleichgewicht zwischen Geschwindigkeit und Kosten (vorherige Generation)" - anthropic-claude-3-5-haiku-latest: "Schnell und kosteneffizient" - google-gemini-2-5-pro: "Großes multimodales Modell, das eine Vielzahl von Aufgaben bewältigen kann" - google-gemini-2-0-flash: "Leicht, schnell und kosteneffizient mit multimodaler Argumentation (vorherige Generation)" - google-gemini-2-5-flash: "Leicht, schnell und kosteneffizient mit multimodaler Argumentation" - google-gemini-2-0-flash-lite: "Kosteneffizientes Modell mit niedriger Latenz" - open_ai-o3: "Das leistungsfähigste Argumentationsmodell von Open AI" - open_ai-o4-mini: "Erweitertes kosteneffizientes Argumentationsmodell" - open_ai-gpt-4-1: "Das Vorzeigemodell von Open AI. Es ist gut geeignet für Problemlösungen in verschiedenen Bereichen" - open_ai-gpt-4-1-mini: "Bietet ein ausgewogenes Verhältnis zwischen Intelligenz, Geschwindigkeit und Kosten, was es zu einem attraktiven Modell für viele Anwendungsfälle macht." - open_ai-gpt-4-1-nano: "Das schnellste und kostengünstigste GPT-4.1-Modell." - samba_nova-Meta-Llama-3-1-8B-Instruct: "Effizientes leichtgewichtiges mehrsprachiges Modell" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Leistungsstarkes Mehrzweckmodell" - mistral-mistral-large-latest: "Das leistungsstärkste Modell von Mistral" - mistral-pixtral-large-latest: "Das leistungsstärkste Modell von Mistral mit Sichtfunktion" - open_router-x-ai-grok-3-beta: "Das neueste Modell von xAI" - open_router-deepseek-deepseek-r1-0528-free: "DeepSeeks neuestes Argumentationsmodell" - open_router-meta-llama-3-3-70b-instruct: "Leistungsstarkes mehrsprachiges Modell" - preseeded_model_description: "Vorkonfiguriertes Open-Source-Modell unter Verwendung von %{model}" - configured: - title: "Konfigurierte LLMs" - preconfigured_llms: "Wähle dein LLM" - preconfigured: - title_no_llms: "Wähle eine Vorlage aus, um loszulegen" - title: "Unkonfigurierte LLM-Vorlagen" - description: "LLMs (Large Language Models) sind KI-Tools, die für Aufgaben wie die Zusammenfassung von Inhalten, die Erstellung von Berichten, die Automatisierung von Kundeninteraktionen und die Erleichterung der Forenmoderation und -einsicht optimiert sind" - fake: "Manuelle Konfiguration" - button: "Einrichten" - next: - title: "Nächstes" - tests: - title: "Test ausführen" - running: "Test wird aufgeführt …" - success: "Erfolg!" - failure: "Beim Versuch, das Modell zu kontaktieren, wurde dieser Fehler zurückgegeben: %{error}" - hints: - max_prompt_tokens: "Die maximale Anzahl von Token, die das Modell in einer einzigen Anfrage verarbeiten kann" - max_output_tokens: "Die maximale Anzahl von Token, die das Modell in einer einzigen Anfrage generieren kann" - display_name: "Der Name, der verwendet wird, um dieses Modell in der Benutzeroberfläche deiner Website zu referenzieren." - name: "Wir fügen dies in den API-Aufruf ein, um anzugeben, welches Modell wir verwenden werden" - vision_enabled: "Wenn diese Funktion aktiviert ist, versucht die KI, Bilder zu verstehen. Dafür wird ein Modell benötigt, das Sehen unterstützt. Verfügbar in den neuesten Modellen von Anthropic, Google und OpenAI." - enabled_chat_bot: "Wenn diese Option aktiviert ist, können Benutzer dieses Modell auswählen, wenn sie PN mit dem KI-Bot erstellen" - cost_input: "Die Kosten für die Eingabe pro 1 Million Token für dieses Modell" - cost_cached_input: "Die Kosten für die Eingabe von Zwischengespeichertem pro 1 Million Token für dieses Modell" - cost_output: "Die Kosten für die Ausgabe pro 1 Million Token für dieses Modell" - cost_measure: "$/1M Token" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Benutzerdefiniert" - provider_fields: - access_key_id: "AWS-Bedrock-Zugangsschlüssel-ID" - region: "AWS-Bedrock-Region" - organization: "Optionale OpenAI-Organisations-ID" - disable_system_prompt: "Systemmeldung in Eingabeaufforderungen deaktivieren" - enable_native_tool: "Aktiviere native Tool-Unterstützung" - disable_native_tools: "Native Tool-Unterstützung deaktivieren (XML-basierte Tools verwenden)" - provider_order: "Anbieterreihenfolge (kommagetrennte Liste)" - provider_quantizations: "Reihenfolge der Provider-Quantisierungen (kommagetrennte Liste, z. B.: fp16,fp8)" - disable_streaming: "Streaming-Vervollständigung deaktivieren (Streaming-Anfragen in Nicht-Streaming-Anfragen umwandeln)" - reasoning_effort: "Argumentationsaufwand (gilt nur für Argumentationsmodelle)" - enable_reasoning: "Argumentation aktivieren (nur für Argumentationsmodelle anwendbar)" - enable_thinking: "Denken aktivieren (nur bei entsprechenden Modellen, z.B.: Flash 2.5)" - thinking_tokens: "Anzahl der zum Denken verwendeten Token" - reasoning_tokens: "Anzahl der für die Argumentation verwendeten Token" - disable_temperature: "Temperatur deaktivieren (einige Denkmodelle unterstützen keine Temperatur)" - disable_top_p: "Top P deaktivieren (einige Denkmodelle unterstützen Top P nicht)" - enable_responses_api: "Aktiviere die Antwort-API (bei bestimmten OpenAI-Modellen erforderlich)" - related_topics: - title: "Verwandte Themen" - pill: "Verwandt" - ai_helper: - title: "Änderungen mit KI vorschlagen" - description: "Wähle eine der folgenden Optionen und die KI schlägt dir eine neue Version des Textes vor." - selection_hint: "Tipp: Du kannst auch einen Teil des Textes auswählen, bevor du den Assistenten öffnest, um nur diesen Teil neu zu schreiben." - suggest: "Mit KI vorschlagen" - suggest_errors: - too_many_tags: - one: "Du kannst höchstens %{count} Schlagwort haben." - other: "Du kannst höchstens %{count} Schlagwörter haben." - no_suggestions: "Keine Vorschläge verfügbar" - missing_content: "Bitte gib einige Inhalte ein, um Vorschläge zu generieren." - context_menu: - trigger: "KI fragen" - loading: "KI generiert" - cancel: "Abbrechen" - regen: "Erneut versuchen" - confirm: "Bestätigen" - discard: "Verwerfen" - changes: "Empfohlene Änderungen" - custom_prompt: - title: "Benutzerdefinierte Eingabeaufforderung" - placeholder: "Benutzerdefinierte Eingabeaufforderung …" - submit: "Aufforderung senden" - translate_prompt: "Übersetzen in %{language}" - post_options_menu: - trigger: "KI fragen" - title: "KI fragen" - loading: "KI generiert" - close: "Schließen" - copy: "Kopieren" - copied: "Kopiert!" - cancel: "Abbrechen" - insert_footnote: "Fußnote hinzufügen" - footnote_disabled: "Automatisches Einfügen deaktiviert. Klicke auf die Schaltfläche „Kopieren“ und bearbeite es manuell" - footnote_credits: "Erklärung durch KI" - fast_edit: - suggest_button: "Bearbeitung vorschlagen" - thumbnail_suggestions: - title: "Vorgeschlagene Miniaturansichten" - select: "Auswählen" - selected: "Ausgewählt" - image_caption: - button_label: "Beschriftung mit KI" - generating: "Beschriftung wird generiert …" - credits: "Beschriftet durch KI" - save_caption: "Speichern" - automatic_caption_setting: "Automatische Beschriftung einschalten" - automatic_caption_loading: "Bilder werden beschriftet …" - automatic_caption_dialog: - prompt: "Dieser Beitrag enthält nicht beschriftete Bilder. Möchtest du automatische Beschriftungen für hochgeladene Bilder aktivieren? (Das kannst du später in deinen Einstellungen ändern.)" - confirm: "Aktivieren" - cancel: "Nicht noch einmal fragen" - no_content_error: "Füge zuerst Inhalte hinzu, um KI-Aktionen darauf anzuwenden" - reviewables: - model_used: "Verwendetes Modell:" - accuracy: "Genauigkeit:" - embeddings: - short_title: "Einbettungen" - description: "Einbettungen sind numerische Darstellungen von Daten, die Bedeutungen und Beziehungen erfassen und es Discourse-KI-Funktionen wie „Verwandte Themen“ und „KI-Suche“ ermöglichen, Inhalte zu verstehen und zu verknüpfen." - new: "Neue Einbettung" - back: "Zurück" - save: "Speichern" - saved: "Einbettungskonfiguration gespeichert" - delete: "Löschen" - confirm_delete: Bist du sicher, dass du diese Einbettungskonfiguration entfernen möchtest? - empty: "Du hast noch keine Einbettungen eingerichtet" - presets: "Wähle eine Voreinstellung aus …" - configure_manually: "Manuell konfigurieren" - edit: "Bearbeiten" - seeded_warning: "Dies ist auf deiner Website vorkonfiguriert und kann nicht bearbeitet werden." - tests: - title: "Test ausführen" - running: "Test wird aufgeführt …" - success: "Erfolg!" - failure: "Der Versuch, eine Einbettung zu generieren, ergab: %{error}" - hints: - dimensions_warning: "Einmal gespeichert, kann dieser Wert nicht mehr geändert werden." - matryoshka_dimensions: "Legt die Größe der verschachtelten Einbettungen fest, die zur hierarchischen oder mehrschichtigen Darstellung von Daten verwendet werden, ähnlich wie verschachtelte Puppen ineinander passen." - embed_prompt: "Präfix für Aufgabenanweisungen beim Generieren von Einbettungen von Foreninhalten. NUR für Einbettungsmodelle, die Präfixe erfordern, wie nomic-embed oder stella. Für die meisten Modelle nicht erforderlich." - search_prompt: "Präfix für Task-Anweisungen beim Generieren von Einbettungen von Suchanfragen. NUR für Einbettungsmodelle, die Präfixe erfordern, wie nomic-embed oder stella. Für die meisten Modelle nicht erforderlich." - sequence_length: "Die maximale Anzahl von Token, die bei der Erstellung von Einbettungen oder der Bearbeitung einer Abfrage auf einmal verarbeitet werden können." - distance_function: "Legt fest, wie die Ähnlichkeit zwischen Einbettungen berechnet wird. Dabei wird entweder der Kosinusabstand (der den Winkel zwischen Vektoren misst) oder das negative innere Produkt (das die Überlappung von Vektorwerten misst) verwendet." - display_name: "Name" - provider: "Anbieter" - url: "URL des Einbettungsdienstes" - api_key: "API-Schlüssel für den Einbettungsdienst" - tokenizer: "Tokenizer" - dimensions: "Einbettungsdimensionen" - max_sequence_length: "Länge der Sequenz" - embed_prompt: "Einbettungsaufforderung" - search_prompt: "Suchaufforderung" - matryoshka_dimensions: "Matrjoschka-Abmessungen" - distance_function: "Distanzfunktion" - distance_functions: - "<#>": "Negatives inneres Produkt" - <=>: "Kosinusdistanz" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Benutzerdefiniert" - provider_fields: - model_name: "Modellname" - semantic_search: "Themen (semantisch)" - semantic_search_loading: "Suche mehr Ergebnisse mithilfe der KI" - semantic_search_results: - toggle: "%{count} Ergebnisse, die mit KI gefunden wurden, werden angezeigt" - toggle_hidden: "%{count} mit KI gefundene Ergebnisse werden ausgeblendet" - none: "Entschuldigung, unsere KI-Suche hat keine passenden Themen gefunden" - new: "Drücke auf „Suchen“, um mit der KI nach neuen Ergebnissen zu suchen" - unavailable: "KI-Ergebnisse nicht verfügbar" - semantic_search_tooltips: - results_explanation: "Wenn diese Funktion aktiviert ist, werden zusätzliche KI-Suchergebnisse unten hinzugefügt." - invalid_sort: "Die Suchergebnisse müssen nach Relevanz sortiert werden, um KI-Ergebnisse anzuzeigen" - semantic_search_unavailable_tooltip: "Die Suchergebnisse müssen nach Relevanz sortiert werden, um KI-Ergebnisse anzuzeigen" - ai_generated_result: "Suchergebnis mit KI gefunden" - quick_search: - suffix: "in allen Themen und Beiträgen mit KI" - ai_artifact: - expand_view_label: "Ansicht erweitern" - collapse_view_label: "Vollbild verlassen (ESC- oder Zurück-Taste)" - click_to_run_label: "Artefakt ausführen" - ai_bot: - persona: "Persona" - llm: "Modell" - pm_warning: "KI-Chatbot-Nachrichten werden regelmäßig von Moderatoren überwacht." - cancel_streaming: "Antwort abbrechen" - default_pm_prefix: "[KI-Bot-PN ohne Titel]" - shortcut_title: "Starte eine PN mit einem KI-Bot" - share: "KI-Unterhaltung kopieren" - conversation_shared: "Unterhaltung kopiert" - embed_copied: "Einbettung in die Zwischenablage kopiert" - debug_ai: "Rohdaten der KI-Anfrage und -Antwort anzeigen" - sidebar_empty: "Der Verlauf der Bot-Konversation wird hier angezeigt." - debug_ai_modal: - title: "KI-Interaktion ansehen" - copy_request: "Anfrage kopieren" - copy_response: "Antwort kopieren" - request_tokens: "Anfrage-Token:" - response_tokens: "Antwort-Token:" - request: "Anfrage" - response: "Antwort" - next_log: "Weiter" - previous_log: "Zurück" - share_full_topic_modal: - title: "Unterhaltung öffentlich teilen" - share: "Link teilen und kopieren" - update: "Link aktualisieren und kopieren" - delete: "Freigabe löschen" - share_ai_conversation: - name: "KI-Unterhaltung teilen" - title: "Diese KI-Unterhaltung öffentlich teilen" - invite_ai_conversation: - button: "Einladen" - title: "Zur KI-Konversation einladen" - ai_label: "KI" - ai_title: "Unterhaltung mit KI" - share_modal: - title: "KI-Unterhaltung kopieren" - copy: "Kopieren" - context: "Interaktionen zum Teilen:" - share_tip: "Alternativ kannst du auch die gesamte Unterhaltung teilen" - bot_names: - fake: "Fake-Test-Bot" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonett" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - header: "Wie kann ich helfen?" - submit: "Frage abschicken" - disclaimer: "Generative KI kann Fehler machen. Überprüfe wichtige Informationen." - placeholder: "Stell eine Frage ..." - new: "Neue Frage" - min_input_length_message: - one: "Nachricht muss mindestens 1 Zeichen lang sein" - other: "Nachricht muss mindestens %{count} Zeichen lang sein" - messages_sidebar_title: "Unterhaltungen" - today: "Heute" - last_7_days: "Letzte 7 Tage" - last_30_days: "Letzte 30 Tage" - upload_files: "Dateien hochladen" - sentiments: - dashboard: - title: "Stimmung" - sidebar: - overview: "Stimmungsübersicht" - analysis: "Stimmungsanalyse" - sentiment_analysis: - share_chart: "Link zur Tabelle kopieren" - filter_types: - all: "Gesamt" - positive: "Positiv" - neutral: "Neutral" - negative: "Negativ" - group_types: - category: "Kategorie" - tag: "Schlagwort" - table: - sentiment: "Stimmung" - total_count: "Gesamt" - summarization: - chat: - title: "Nachrichten zusammenfassen" - description: "Wähle unten eine Option aus, um die im gewünschten Zeitraum gesendete Unterhaltung zusammenzufassen." - summarize: "Zusammenfassen" - since: - one: "Letzte Stunde" - other: "Letzte %{count} Stunden" - topic: - title: "Zusammenfassung des Themas" - close: "Zusammenfassungspanel schließen" - topic_list_layout: - button: - compact: "Kompakt" - expanded: "Erweitert" - expanded_description: "mit KI-Zusammenfassungen" - discobot_discoveries: - main_title: "Discobot Entdeckungen" - regular_results: "Themen" - tell_me_more: "Erzähl mir mehr..." - continue_convo: "Unterhaltung fortsetzen..." - loading_convo: "Unterhaltung wird geladen" - collapse: "Zuklappen" - timed_out: "Discobot konnte keine Entdeckungen finden. Irgendetwas ist schief gelaufen." - user_setting: "Suchentdeckungen aktivieren" - tooltip: - header: "KI-gestützte Suche" - content: "Natürliche Sprachsuche unterstützt von %{model}" - actions: - info: "Wie funktioniert es?" - disable: "Deaktivieren" - user_preferences: - empty: "Zurzeit sind keine relevanten Einstellungen verfügbar" - review: - types: - reviewable_ai_post: - title: "KI-gemeldeter Beitrag" - reviewable_ai_chat_message: - title: "KI-gemeldete Chat-Nachricht" diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml deleted file mode 100644 index 1bea01c1..00000000 --- a/config/locales/client.el.yml +++ /dev/null @@ -1,215 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -el: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Επιτρέπει την αναζήτηση AI" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - reports: - filters: - group_by: - label: "Ομαδοποίηση κατά" - sort_by: - label: "Ταξινόμηση κατά" - tag: - label: "Ετικέτα" - logs: - staff_actions: - actions: - create_ai_llm_model: "Δημιουργία μοντέλου LLM" - update_ai_llm_model: "Ενημέρωση μοντέλου LLM" - delete_ai_llm_model: "Διαγραφή μοντέλου LLM" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Αποστολέας" - description: "Ο χρήστης που θα στείλει την αναφορά" - receivers: - label: "Παραλήπτες" - description: "Οι χρήστες που θα λάβουν την αναφορά (τα emails θα αποσταλούν απευθείας με email, τα ονόματα χρηστών θα αποσταλούν με ΠΜ)" - topic_id: - label: "ID Θέματος" - description: "Το ID θέματος στο οποίο θα δημοσιεύεται η αναφορά" - title: - label: "Τίτλος" - description: "Ο τίτλος της αναφοράς" - days: - label: "Ημέρες" - description: "Το χρονικό διάστημα της αναφοράς" - instructions: - label: "Οδηγίες" - sample_size: - label: "Μέγεθος δείγματος" - model: - label: "Μοντέλο" - categories: - label: "Κατηγορίες" - tags: - label: "Ετικέτες" - llm_triage: - fields: - category: - label: "Κατηγορία" - tags: - label: "Ετικέτες" - canned_reply: - label: "Απάντηση" - discourse_ai: - features: - back: "Πίσω" - disabled: "(απενεργοποιημένο)" - groups: "Ομάδες:" - no_persona: "Δεν έχει οριστεί" - no_groups: "Κανένα" - edit: "Επεξεργασία" - expand_list: - one: "(%{count} περισσότερο)" - other: "(%{count} περισσότερα)" - collapse_list: "(δείξε λιγότερα)" - filters: - all: "Όλα" - reset: "Επαναφορά" - search: - name: "Αναζήτηση" - spam: - name: "Ανεπιθύμητα" - modals: - select_option: "Διαλέξτε μία επιλογή..." - spam: - short_title: "Ανεπιθύμητα" - last_seven_days: "Τελευταίες 7 ημέρες" - enable: "Ενεργοποίηση" - test_modal: - result: "Αποτέλεσμα" - spam: "Ανεπιθύμητα" - usage: - summary: "Περίληψη" - username: "Όνομα Χρήστη" - total_requests: "Σύνολο αιτήσεων" - periods: - last_day: "Τελευταίες 24 ώρες" - custom: "Προσαρμοσμένο..." - ai_persona: - back: "Πίσω" - name: "Όνομα" - edit: "Επεξεργασία" - export: "Εξαγωγή" - description: "Περιγραφή" - user: Χρήστης - save: "Αποθήκευση" - enabled: "Ενεργοποιημένο;" - allowed_groups: "Επιτρεπόμενες ομάδες" - delete: "Σβήσιμο" - response_format: - open_modal: "Επεξεργασία" - modal: - key_title: "Κλειδί" - filters: - reset: "Επαναφορά" - rag: - uploads: - title: "Μεταφορτώσεις" - uploading: "Επιφόρτωση..." - tools: - back: "Πίσω" - export: "Εξαγωγή" - name: "Όνομα" - description: "Περιγραφή" - summary: "Περίληψη" - save: "Αποθήκευση" - remove_parameter: "Αφαίρεση" - parameter_required: "Απαιτείται" - edit: "Επεξεργασία" - delete: "Σβήσιμο" - llms: - display_name: "Όνομα" - save: "Αποθήκευση" - edit: "Επεξεργασία" - back: "Πίσω" - delete: Σβήσιμο - quotas: - group: "Ομάδα" - max_usages: "Μέγιστες χρήσεις" - duration: "Διάρκεια" - durations: - hour: "1 ώρα" - six_hours: "6 ώρες" - day: "24 ώρες" - week: "7 ημέρες" - custom: "Προσαρμοσμένο..." - hours: "ώρες" - usage: - ai_spam: "Ανεπιθύμητα" - next: - title: "Επόμενο" - tests: - success: "Επιτυχία!" - providers: - google: "Google" - fake: "Προσαρμοσμένο" - ai_helper: - context_menu: - cancel: "Ακύρωση" - discard: "Απόρριψη" - post_options_menu: - close: "Κλείσιμο" - copy: "Αντιγραφή" - copied: "Αντιγράφηκε!" - cancel: "Ακύρωση" - image_caption: - save_caption: "Αποθήκευση" - automatic_caption_dialog: - confirm: "Ενεργοποίηση" - embeddings: - back: "Πίσω" - save: "Αποθήκευση" - delete: "Σβήσιμο" - edit: "Επεξεργασία" - tests: - success: "Επιτυχία!" - display_name: "Όνομα" - providers: - google: "Google" - fake: "Προσαρμοσμένο" - ai_bot: - debug_ai_modal: - request: "Αίτημα" - response: "Απάντηση" - next_log: "Επόμενο" - previous_log: "Προηγούμενο" - invite_ai_conversation: - button: "Πρόσκληση" - share_modal: - copy: "Αντιγραφή" - conversations: - today: "Σήμερα" - last_7_days: "Τελευταίες 7 ημέρες" - last_30_days: "Τελευταίες 30 ημέρες" - sentiments: - sentiment_analysis: - filter_types: - all: "Όλα" - group_types: - category: "Κατηγορία" - table: - total_count: "Σύνολο" - discobot_discoveries: - regular_results: "Θέματα" - collapse: "Σύμπτυξη" - tooltip: - actions: - disable: "Απενεργοποίηση" diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml deleted file mode 100644 index 5a577657..00000000 --- a/config/locales/client.en.yml +++ /dev/null @@ -1,942 +0,0 @@ -en: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Allows AI search" - stream_completion: "Allows streaming AI persona completions" - update_personas: "Allows updating AI personas" - - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "Emotion" - description: "The table lists a count of posts classified with a determined emotion. Classified with the model 'SamLowe/roberta-base-go_emotions'." - reports: - filters: - group_by: - label: "Group by" - sort_by: - label: "Sort by" - tag: - label: "Tag" - logs: - staff_actions: - actions: - create_ai_llm_model: "Create LLM model" - update_ai_llm_model: "Update LLM model" - delete_ai_llm_model: "Delete LLM model" - create_ai_persona: "Create AI persona" - update_ai_persona: "Update AI persona" - delete_ai_persona: "Delete AI persona" - create_ai_tool: "Create AI tool" - update_ai_tool: "Update AI tool" - delete_ai_tool: "Delete AI tool" - create_ai_embedding: "Create AI embedding" - update_ai_embedding: "Update AI embedding" - delete_ai_embedding: "Delete AI embedding" - update_ai_spam_settings: "Update AI spam settings" - - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Sender" - description: "The user that will send the report" - receivers: - label: "Receivers" - description: "The users that will receive the report (emails will be sent direct emails, usernames will be sent a PM)" - topic_id: - label: "Topic ID" - description: "The topic ID to post the report to" - title: - label: "Title" - description: "The title of the report" - days: - label: "Days" - description: "The timespan of the report" - offset: - label: "Offset" - description: "When testing you may want to run the report historically, use offset to start the report in an earlier date" - instructions: - label: "Instructions" - description: "The instructions provided to the large language model" - sample_size: - label: "Sample Size" - description: "The number of posts to sample for the report" - tokens_per_post: - label: "Tokens per post" - description: "The number of LLM tokens to use per post" - model: - label: "Model" - description: "LLM to use for report generation" - categories: - label: "Categories" - description: "Filter topics only to these categories" - tags: - label: "Tags" - description: "Filter topics only to these tags" - exclude_tags: - label: "Exclude Tags" - description: "Exclude topics with these tags" - exclude_categories: - label: "Exclude Categories" - description: "Exclude topics with these categories" - allow_secure_categories: - label: "Allow secure categories" - description: "Allow the report to be generated for topics in secure categories" - suppress_notifications: - label: "Suppress Notifications" - description: "Suppress notifications the report may generate by transforming to content. This will remap mentions and internal links." - debug_mode: - label: "Debug Mode" - description: "Enable debug mode to see the raw input and output of the LLM" - priority_group: - label: "Priority Group" - description: "Prioritize content from this group in the report" - temperature: - label: "Temperature" - description: "Temperature to use for the LLM. Increase to increase randomness (leave empty to use model default)" - top_p: - label: "Top P" - description: "Top P to use for the LLM, increase to increase randomness (leave empty to use model default)" - persona_id: - label: "Persona" - description: "AI Persona to use for report generation" - - llm_tool_triage: - fields: - model: - label: "Model" - description: "The default language model used for triage" - tool: - label: "Tool" - description: "Tool to use for triage (tool must have no parameters defined)" - - llm_persona_triage: - fields: - persona: - label: "Persona" - description: "AI Persona to use for triage (must have default LLM and User set)" - whisper: - label: "Reply as Whisper" - description: "Whether the persona's response should be a whisper" - silent_mode: - label: "Silent Mode" - description: "In silent mode persona will receive the content but will not post anything on the forum - useful when performing triage using tools" - llm_triage: - fields: - system_prompt: - label: "System Prompt" - description: "The prompt that will be used to triage, be sure for it to reply with a single word you can use to trigger the action" - max_post_tokens: - label: "Max Post Tokens" - description: "The maximum number of tokens to scan using LLM triage" - stop_sequences: - label: "Stop Sequences" - description: "Instruct the model to halt token generation when arriving at one of these values" - search_for_text: - label: "Search for text" - description: "If the following text appears in the LLM reply, apply these actions" - category: - label: "Category" - description: "Category to apply to the topic" - tags: - label: "Tags" - description: "Tags to apply to the topic" - canned_reply: - label: "Reply" - description: "Raw text of the canned reply to post on the topic" - canned_reply_user: - label: "Reply User" - description: "Username of the user to post the canned reply" - hide_topic: - label: "Hide topic" - description: "Make topic not visible to the public if triggered" - flag_type: - label: "Flag type" - description: "Type of flag to apply to the post (spam or simply raise for review)" - flag_post: - label: "Flag post" - description: "Flags post (either as spam or for review)" - include_personal_messages: - label: "Include personal messages" - description: "Also scan and triage personal messages" - whisper: - label: "Reply as Whisper" - description: "Whether the AI's response should be a whisper" - reply_persona: - label: "Reply Persona" - description: "AI Persona to use for replies (must have default LLM), will be prioritized over canned reply" - model: - label: "Model" - description: "Language model used for triage" - temperature: - label: "Temperature" - description: "Temperature to use for the LLM. Increase to increase randomness (leave empty to use model default)" - max_output_tokens: - label: "Max output tokens" - description: "When specified, sets an upper bound to the maximum number of tokens the model can generate. Respects LLM's max output tokens limit" - - discourse_ai: - title: "AI" - - features: - short_title: "Features" - description: "These are the AI features available to visitors on your site. These can be configured to use specific personas and LLMs, and can be access controlled by groups." - back: "Back" - disabled: "(disabled)" - persona: - one: "Persona:" - other: "Personas:" - groups: "Groups:" - llm: - one: "LLM:" - other: "LLMs:" - no_llm: "No LLM selected" - no_persona: "Not set" - no_groups: "None" - edit: "Edit" - expand_list: - one: "(%{count} more)" - other: "(%{count} more)" - collapse_list: "(show less)" - bot: - bot: "Chatbot" - name: "Bot" - description: "A chat bot that can answer questions and assist users in personal messages, forum and in chat" - nav: - configured: "Configured" - unconfigured: "Unconfigured" - filters: - all: "All" - text: "Search features, personas, LLMs, or groups..." - no_results: "No features found matching your filters." - reset: "Reset" - summarization: - name: "Summaries" - description: "Makes a summarization button available that allows visitors to summarize topics" - topic_summaries: "Topic summaries" - gists: "Topic list's short summaries" - search: - name: "Search" - description: "Enhances search experience by providing AI-generated answers to queries" - discoveries: "Discoveries" - embeddings: - name: "Embeddings" - description: "Powers features like Related Topics and AI Search by generating semantic representations of text" - hyde: "HyDE" - discord: - name: "Discord integration" - description: "Adds the ability to search Discord channels" - search: "Discord search" - inference: - name: "Inferred concepts" - description: "Classifies topics and posts into areas of interest / labels." - generate_concepts: "Concepts inference" - match_concepts: "Concepts matching" - deduplicate_concepts: "Concepts deduplication" - - ai_helper: - name: "Helper" - description: "Assists users in community interaction, such as creating topics, writing posts, and reading content" - proofread: Proofread text - title_suggestions: "Suggest titles" - explain: "Explain" - illustrate_post: "Illustrate post" - smart_dates: "Smart dates" - translate: "Translate" - markdown_tables: "Generate Markdown table" - custom_prompt: "Custom prompt" - image_caption: "Caption images" - translator: "Translator" - - translation: - name: "Translation" - description: "Translates content into supported languages" - locale_detector: "Locale detector" - post_raw_translator: "Post raw translator" - topic_title_translator: "Topic title translator" - short_text_translator: "Short text translator" - - spam: - name: "Spam" - description: "Identifies potential spam using the selected LLM and flags it for site moderators to inspect in the review queue" - inspect_posts: "Inspect posts" - - modals: - select_option: "Select an option..." - - layout: - table: "Table" - card: "Card" - - spam: - short_title: "Spam" - title: "Configure spam handling" - select_llm: "Select LLM" - select_persona: "Select persona" - custom_instructions: "Custom instructions" - custom_instructions_help: "Custom instructions specific to your site to help guide the AI in identifying spam, e.g. 'Be more aggressive about scanning posts not in English'." - last_seven_days: "Last 7 days" - scanned_count: "Posts scanned" - false_positives: "Incorrectly flagged" - false_negatives: "Missed spam" - spam_detected: "Spam detected" - custom_instructions_placeholder: "Site-specific instructions for the AI to help identify spam more accurately" - enable: "Enable" - spam_tip: "AI spam detection will scan the first 3 posts by all new users on public topics. It will flag them for review and block users if they are likely spam." - settings_saved: "Settings saved" - spam_description: "Identifies potential spam using the selected LLM and flags it for site moderators to inspect in the review queue" - no_llms: "No LLMs available" - test_button: "Test..." - save_button: "Save changes" - test_modal: - title: "Test spam detection" - post_url_label: "Post URL or ID" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 or post ID" - result: "Result" - scan_log: "Scan log" - run: "Run test" - spam: "Spam" - not_spam: "Not spam" - stat_tooltips: - incorrectly_flagged: "Items that the AI bot flagged as spam where moderators disagreed" - missed_spam: "Items flagged by the community as spam that were not detected by the AI bot, which moderators agreed with" - errors: - scan_not_admin: - message: "Warning: spam scanning will not work correctly because the spam scan account is not an admin" - action: "Fix" - resolved: "The error has been resolved!" - - usage: - short_title: "Usage" - summary: "Summary" - total_tokens: "Total tokens" - tokens_over_time: "Tokens over time" - features_breakdown: "Usage per feature" - feature: "Feature" - usage_count: "Usage count" - model: "Model" - models_breakdown: "Usage per model" - users_breakdown: "Usage per user" - all_features: "All features" - all_models: "All models" - username: "Username" - total_requests: "Total requests" - request_tokens: "Request tokens" - response_tokens: "Response tokens" - net_request_tokens: "Net request tokens" - cached_tokens: "Cached tokens" - cached_request_tokens: "Cached request tokens" - total_spending: "Estimated cost" - no_users: "No user usage data found" - no_models: "No model usage data found" - no_features: "No feature usage data found" - subheader_description: "Tokens are the basic units that LLMs use to understand and generate text, usage data may affect costs" - stat_tooltips: - total_requests: "All requests made to LLMs through Discourse" - total_tokens: "All the tokens used when prompting an LLM" - request_tokens: "Tokens used when the LLM tries to understand what you are saying" - response_tokens: "Tokens used when the LLM responds to your prompt" - cached_tokens: "Previously processed request tokens that the LLM reuses to optimize performance and cost" - total_spending: "Cumulative cost of all tokens used by the LLMs based on specified cost metrics added to LLM configuration settings" - periods: - last_day: "Last 24 hours" - last_week: "Last week" - last_month: "Last month" - custom: "Custom..." - - ai_persona: - ai_tools: "Tools" - tool_strategies: - all: "Apply to all replies" - replies: - one: "Apply to first reply only" - other: "Apply to first %{count} replies" - back: "Back" - name: "Name" - edit: "Edit" - export: "Export" - import: "Import" - import_error_conflict: "Conflict detected importing %{name}, would you like to update the existing persona?" - overwrite: "Overwrite" - description: "Description" - no_llm_selected: "No language model selected" - use_parent_llm: "Use personas language model" - max_context_posts: "Max context posts" - max_context_posts_help: "The maximum number of posts to use as context for the AI when responding to a user. (empty for default)" - vision_enabled: Vision enabled - vision_enabled_help: If enabled, the AI will attempt to understand images users post in the topic, depends on the model being used supporting vision. Supported by latest models from Anthropic, Google, and OpenAI. - vision_max_pixels: Supported image size - vision_max_pixel_sizes: - low: Low quality - cheapest (256x256) - medium: Medium quality (512x512) - high: High quality - slowest (1024x1024) - tool_details: Show tool details - tool_details_help: Will show end users details on which tools the language model has triggered. - mentionable: Allow mentions - mentionable_help: If enabled, users in allowed groups can mention this user in posts, the AI will respond as this persona. - user: User - create_user: Create user - create_user_help: You can optionally attach a user to this persona. If you do, the AI will use this user to respond to requests. - default_llm: Default language model - default_llm_help: The default language model to use for this persona. Required if you wish to mention persona on public posts. - question_consolidator_llm: Language Model for Question Consolidator - question_consolidator_llm_help: The language model to use for the question consolidator, you may choose a less powerful model to save costs. - system_prompt: System prompt - forced_tool_strategy: Forced tool strategy - allow_chat_direct_messages: "Allow chat direct messages" - allow_chat_direct_messages_help: "If enabled, users in allowed groups can send direct messages to this persona." - allow_chat_channel_mentions: "Allow chat channel mentions" - allow_chat_channel_mentions_help: "If enabled, users in allowed groups can mention this persona in chat channels." - allow_personal_messages: "Allow personal messages" - allow_personal_messages_help: "If enabled, users in allowed groups can send personal messages to this persona." - allow_topic_mentions: "Allow topic mentions" - allow_topic_mentions_help: "If enabled, users in allowed groups can mention this persona in topics." - force_default_llm: "Always use default language model" - save: "Save" - saved: "Persona saved" - enabled: "Enabled?" - tools: "Enabled tools" - forced_tools: "Forced tools" - allowed_groups: "Allowed groups" - confirm_delete: "Are you sure you want to delete this persona?" - new: "New persona" - no_personas: "You have not created any personas yet" - title: "Personas" - short_title: "Personas" - delete: "Delete" - temperature: "Temperature" - temperature_help: "Temperature to use for the LLM. Increase to increase creativity (leave empty to use model default, generally a value from 0.0 to 2.0)" - top_p: "Top P" - top_p_help: "Top P to use for the LLM, increase to increase randomness (leave empty to use model default, generally a value from 0.0 to 1.0)" - priority: "Priority" - priority_help: "Priority personas are displayed to users at the top of the persona list. If multiple personas have priority, they will be sorted alphabetically." - tool_options: "Tool options" - rag_conversation_chunks: "Search conversation chunks" - rag_conversation_chunks_help: "The number of chunks to use for the RAG model searches. Increase to increase the amount of context the AI can use." - persona_description: "Personas are a powerful feature that allows you to customize the behavior of the AI engine in your Discourse forum. They act as a 'system message' that guides the AI's responses and interactions, helping to create a more personalized and engaging user experience." - response_format: - title: "JSON response format" - no_format: "No JSON format specified" - open_modal: "Edit" - modal: - root_title: "Response structure" - key_title: "Key" - examples: - title: Examples - examples_help: Simulate previous interactions with the LLM and ground it to produce better result. - new: New example - remove: Delete example - collapsable_title: "Example #%{number}" - user: "User message" - model: "Model response" - - list: - enabled: "AI Bot?" - - ai_bot: - title: "AI bot options" - save_first: "More AI bot options will become available once you save the persona." - - filters: - text: "Find a persona" - reset: "Reset" - no_results: "No personas found matching your filters." - all_features: "Any feature" - - features_list: - one: "Feature:" - other: "Features:" - - llms_list: "LLM:" - - rag: - title: "RAG" - options: - rag_chunk_tokens: "Upload chunk tokens" - rag_chunk_tokens_help: "The number of tokens to use for each chunk in the RAG model. Increase to increase the amount of context the AI can use. (changing will re-index all uploads)" - rag_chunk_overlap_tokens: "Upload chunk overlap tokens" - rag_chunk_overlap_tokens_help: "The number of tokens to overlap between chunks in the RAG model. (changing will re-index all uploads)" - rag_llm_model: "Indexing Language Model" - rag_llm_model_help: "The language model used for OCR during indexing of PDFs and images" - show_indexing_options: "Show upload options" - hide_indexing_options: "Hide upload options" - uploads: - title: "Uploads" - description: "PDF (.pdf), Plaintext (.txt) or markdown (.md)" - description_with_images: "Plaintext (.txt), markdown (.md), PDF (.pdf) or image (.png, .jpeg)" - button: "Add files" - filter: "Filter uploads" - indexed: "Indexed" - indexing: "Indexing" - uploaded: "Ready to be indexed" - uploading: "Uploading..." - remove: "Remove upload" - - tools: - back: "Back" - short_title: "Tools" - export: "Export" - import: "Import" - import_error_conflict: "Tool already exists, would you like to update it?" - overwrite: "Overwrite" - no_tools: "You have not created any tools yet" - name: "Name" - name_help: "Name will show up in the Discourse UI and is the short identifier you will use to find the tool in various settings, it should be distinct (it is required)" - new: "New tool" - tool_name: "Tool Name" - tool_name_help: "Tool Name is presented to the large language model. It is not distinct, but it is distinct per persona. (persona validates on save)" - description: "Description" - description_help: "A clear description of the tool's purpose for the language model" - subheader_description: "Tools extend the capabilities of AI bots with user-defined JavaScript functions." - summary: "Summary" - summary_help: "Summary of tools purpose to be displayed to end users" - script: "Script" - parameters: "Parameters" - save: "Save" - parameter_type: "Parameter type" - add_parameter: "Add parameter" - remove_parameter: "Remove" - parameter_required: "Required" - parameter_enum: "Enum" - parameter_name: "Parameter name" - parameter_description: "Parameter description" - enum_value: "Enum value" - add_enum_value: "Add enum value" - edit: "Edit" - test: "Run test" - delete: "Delete" - saved: "Tool saved" - confirm_delete: "Are you sure you want to delete this tool?" - test_modal: - title: "Test AI tool" - run: "Run test" - result: "Test result" - - llms: - short_title: "LLMs" - no_llms: "No LLMs yet" - new: "New model" - display_name: "Name" - name: "Model id" - provider: "Provider" - tokenizer: "Tokenizer" - max_prompt_tokens: "Context window" - max_output_tokens: "Max output tokens" - url: "URL of the service hosting the model" - api_key: "API Key of the service hosting the model" - enabled_chat_bot: "Allow AI bot selector" - vision_enabled: "Vision enabled" - ai_bot_user: "AI bot User" - cost_input: "Input cost" - cost_cached_input: "Cached input cost" - cost_output: "Output cost" - - save: "Save" - edit: "Edit" - saved: "LLM model saved" - back: "Back" - confirm_delete: Are you sure you want to delete this model? - delete: Delete - seeded_warning: "This model is pre-configured on your site and cannot be edited." - quotas: - title: "Usage quotas" - add_title: "Create new quota" - group: "Group" - max_tokens: "Max tokens" - max_usages: "Max uses" - duration: "Duration" - confirm_delete: "Are you sure you want to delete this quota?" - add: "Add quota" - durations: - hour: "1 hour" - six_hours: "6 hours" - day: "24 hours" - week: "7 days" - custom: "Custom..." - hours: "hours" - max_tokens_help: "Maximum number of tokens (words and characters) that each user in this group can use within the specified duration. Tokens are the units used by AI models to process text - roughly 1 token = 4 characters or 3/4 of a word." - max_tokens_required: "Must be set if max usages is not set" - max_usages_help: "Maximum number of times each user in this group can use the AI model within the specified duration. This quota is tracked per individual user, not shared across the group." - max_usages_required: "Must be set if max tokens is not set" - usage: - ai_bot: "AI bot" - ai_helper: "Helper" - ai_helper_image_caption: "Image caption" - ai_persona: "Persona (%{persona})" - ai_summarization: "Summarize" - ai_embeddings_semantic_search: "AI search" - ai_spam: "Spam" - automation: "Automation (%{persona})" - in_use_warning: - one: "This model is currently used by %{settings}. If misconfigured, the feature won't work as expected." - other: "This model is currently used by the following: %{settings}. If misconfigured, features won't work as expected. " - - model_description: - none: "General settings that work for most language models" - anthropic-claude-opus-4-0: "Anthropic's most intelligent model" - anthropic-claude-sonnet-4-0: "Optimal balance of speed and cost" - anthropic-claude-3-7-sonnet-latest: "Optimal balance of speed and cost (previous generation)" - anthropic-claude-3-5-haiku-latest: "Fast and cost-effective" - google-gemini-2-5-pro: "Large multimodal model capable of a wide range of tasks" - google-gemini-2-0-flash: "Lightweight, fast, and cost-efficient with multimodal reasoning (previous generation)" - google-gemini-2-5-flash: "Lightweight, fast, and cost-efficient with multimodal reasoning" - google-gemini-2-0-flash-lite: "Cost efficient and low latency model" - open_ai-o3: "Open AI's most capable reasoning model" - open_ai-o4-mini: "Advanced Cost-efficient reasoning model" - open_ai-gpt-4-1: "Open AI's flagship model. It is well suited for problem solving across domains" - open_ai-gpt-4-1-mini: "Provides a balance between intelligence, speed, and cost that makes it an attractive model for many use cases." - open_ai-gpt-4-1-nano: "Fastest, most cost-effective GPT-4.1 model." - samba_nova-Meta-Llama-3-1-8B-Instruct: "Efficient lightweight multilingual model" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Powerful multipurpose model" - mistral-mistral-large-latest: "Mistral's most powerful model" - mistral-pixtral-large-latest: "Mistral's most powerful vision capable model" - open_router-x-ai-grok-3-beta: "xAI's latest model" - open_router-deepseek-deepseek-r1-0528-free: "DeepSeek's latest reasoning model" - open_router-meta-llama-3-3-70b-instruct: "Highly capable multilingual model" - - preseeded_model_description: "Pre-configured open-source model utilizing %{model}" - - configured: - title: "Configured LLMs" - preconfigured_llms: "Select your LLM" - preconfigured: - title_no_llms: "Select a template to get started" - title: "Unconfigured LLM templates" - description: "LLMs (Large Language Models) are AI tools optimized for tasks like summarizing content, generating reports, automating customer interactions, and facilitating forum moderation and insights" - fake: "Manual configuration" - button: "Set up" - next: - title: "Next" - - tests: - title: "Run test" - running: "Running test..." - success: "Success!" - failure: "Trying to contact the model returned this error: %{error}" - - hints: - max_prompt_tokens: "The maximum number of tokens the model can process in a single request" - max_output_tokens: "The maximum number of tokens the model can generate in a single request" - display_name: "The name used to reference this model across your site's interface." - name: "We include this in the API call to specify which model we'll use" - vision_enabled: "If enabled, the AI will attempt to understand images. It depends on the model being used supporting vision. Supported by latest models from Anthropic, Google, and OpenAI." - enabled_chat_bot: "If enabled, users can select this model when creating PMs with the AI bot" - cost_input: "The input cost per 1M tokens for this model" - cost_cached_input: "The cached input cost per 1M tokens for this model" - cost_output: "The output cost per 1M tokens for this model" - cost_measure: "$/1M tokens" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Custom" - - provider_fields: - access_key_id: "AWS Bedrock access key ID" - region: "AWS Bedrock region" - organization: "Optional OpenAI organization ID" - disable_system_prompt: "Disable system message in prompts" - enable_native_tool: "Enable native tool support" - disable_native_tools: "Disable native tool support (use XML based tools)" - provider_order: "Provider order (comma delimited list)" - provider_quantizations: "Order of provider quantizations (comma delimited list eg: fp16,fp8)" - disable_streaming: "Disable streaming completions (convert streaming to non streaming requests)" - reasoning_effort: "Reasoning effort (only applicable to reasoning models)" - enable_reasoning: "Enable reasoning (only applicable to reasoning models)" - enable_thinking: "Enable thinking (only on applicable models eg: flash 2.5)" - thinking_tokens: "Number of tokens used for thinking" - reasoning_tokens: "Number of tokens used for reasoning" - disable_temperature: "Disable temperature (some thinking models don't support temperature)" - disable_top_p: "Disable top P (some thinking models don't support top P)" - enable_responses_api: "Enable responses API (required on certain OpenAI models)" - - related_topics: - title: "Related topics" - pill: "Related" - ai_helper: - title: "Suggest changes using AI" - description: "Choose one of the options below, and the AI will suggest you a new version of the text." - selection_hint: "Hint: You can also select a portion of the text before opening the helper to rewrite only that." - suggest: "Suggest with AI" - suggest_errors: - too_many_tags: - one: "You can only have up to %{count} tag" - other: "You can only have up to %{count} tags" - no_suggestions: "No suggestions available" - missing_content: "Please enter some content to generate suggestions." - context_menu: - trigger: "Ask AI" - loading: "AI is generating" - cancel: "Cancel" - regen: "Try again" - confirm: "Confirm" - discard: "Discard" - changes: "Suggested edits" - custom_prompt: - title: "Custom prompt" - placeholder: "Enter a custom prompt..." - submit: "Send prompt" - translate_prompt: "Translate to %{language}" - post_options_menu: - trigger: "Ask AI" - title: "Ask AI" - loading: "AI is generating" - close: "Close" - copy: "Copy" - copied: "Copied!" - cancel: "Cancel" - insert_footnote: "Add footnote" - footnote_disabled: "Automatic insertion disabled, click copy button and edit it in manually" - footnote_credits: "Explanation by AI" - fast_edit: - suggest_button: "Suggest edit" - thumbnail_suggestions: - title: "Suggested thumbnails" - select: "Select" - selected: "Selected" - image_caption: - button_label: "Caption with AI" - generating: "Generating caption..." - credits: "Captioned by AI" - save_caption: "Save" - automatic_caption_setting: "Enable auto caption" - automatic_caption_loading: "Captioning images..." - automatic_caption_dialog: - prompt: "This post contains non-captioned images. Would you like to enable automatic captions on image uploads? (This can be changed in your preferences later)" - confirm: "Enable" - cancel: "Don't ask again" - no_content_error: "Add content first to perform AI actions on it" - - reviewables: - model_used: "Model used:" - accuracy: "Accuracy:" - - embeddings: - short_title: "Embeddings" - description: "Embeddings are numerical representations of data that capture meaning and relationships, enabling Discourse AI features like Related Topics and AI Search to understand and connect content." - new: "New embedding" - back: "Back" - save: "Save" - saved: "Embedding configuration saved" - delete: "Delete" - confirm_delete: Are you sure you want to remove this embedding configuration? - empty: "You haven't setup embeddings yet" - presets: "Select a preset..." - configure_manually: "Configure manually" - edit: "Edit" - seeded_warning: "This is pre-configured on your site and cannot be edited." - tests: - title: "Run test" - running: "Running test..." - success: "Success!" - failure: "Attempting to generate an embedding resulted in: %{error}" - hints: - dimensions_warning: "Once saved, this value can't be changed." - matryoshka_dimensions: "Defines the size of nested embeddings used for hierarchical or multi-layered representation of data, similar to how nested dolls fit within each other." - embed_prompt: "Task instruction prefix when generating embeddings of forum content. ONLY for embeddings models that require prefixes, like nomic-embed or stella. Not needed for most models." - search_prompt: "Task instruction prefix when generating embeddings of search queries. ONLY for embeddings models that require prefixes, like nomic-embed or stella. Not needed for most models." - sequence_length: "The maximum number of tokens that can be processed at once when creating embeddings or handling a query." - distance_function: "Determines how similarity between embeddings is calculated, using either cosine distance (measuring the angle between vectors) or negative inner product (measuring overlap of vector values)." - display_name: "Name" - provider: "Provider" - url: "Embeddings service URL" - api_key: "Embeddings service API Key" - tokenizer: "Tokenizer" - dimensions: "Embedding dimensions" - max_sequence_length: "Sequence length" - embed_prompt: "Embed prompt" - search_prompt: "Search prompt" - matryoshka_dimensions: "Matryoshka dimensions" - - distance_function: "Distance function" - distance_functions: - <#>: "Negative inner product" - <=>: "Cosine distance" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Custom" - provider_fields: - model_name: "Model name" - - semantic_search: "Topics (Semantic)" - semantic_search_loading: "Searching for more results using AI" - semantic_search_results: - toggle: "Showing %{count} results found using AI" - toggle_hidden: "Hiding %{count} results found using AI" - none: "Sorry, our AI search found no matching topics" - new: "Press 'search' to begin looking for new results with AI" - unavailable: "AI results unavailable" - semantic_search_tooltips: - results_explanation: "When enabled, additional AI search results will be added below." - invalid_sort: "Search results must be sorted by Relevance to display AI results" - semantic_search_unavailable_tooltip: "Search results must be sorted by Relevance to display AI results" - ai_generated_result: "Search result found using AI" - quick_search: - suffix: "in all topics and posts with AI" - - ai_artifact: - expand_view_label: "Expand view" - collapse_view_label: "Exit Fullscreen (ESC or Back button)" - click_to_run_label: "Run Artifact" - - ai_bot: - persona: "Persona" - llm: "Model" - pm_warning: "AI chatbot messages are monitored regularly by moderators." - cancel_streaming: "Stop reply" - default_pm_prefix: "[Untitled AI bot PM]" - shortcut_title: "Start a PM with an AI bot" - share: "Copy AI conversation" - conversation_shared: "Conversation copied" - embed_copied: "Embed copied to clipboard" - debug_ai: "View raw AI request and response" - sidebar_empty: "Bot conversation history will appear here." - debug_ai_modal: - title: "View AI interaction" - copy_request: "Copy request" - copy_response: "Copy response" - request_tokens: "Request tokens:" - response_tokens: "Response tokens:" - request: "Request" - response: "Response" - next_log: "Next" - previous_log: "Previous" - - share_full_topic_modal: - title: "Share conversation publicly" - share: "Share and copy link" - update: "Update and copy link" - delete: "Delete share" - - share_ai_conversation: - name: "Share AI conversation" - title: "Share this AI conversation publicly" - - invite_ai_conversation: - button: "Invite" - title: "Invite to AI conversation" - - ai_label: "AI" - ai_title: "Conversation with AI" - - share_modal: - title: "Copy AI conversation" - copy: "Copy" - context: "Interactions to share:" - share_tip: "Alternatively, you can share the entire conversation" - - bot_names: - fake: "Fake Test Bot" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - header: "What can I help with?" - submit: "Submit question" - disclaimer: "Generative AI can make mistakes. Verify important information." - placeholder: "Ask a question..." - new: "New Question" - min_input_length_message: - one: "Message must be 1 character or longer" - other: "Message must be %{count} characters or longer" - messages_sidebar_title: "Conversations" - today: "Today" - last_7_days: "Last 7 days" - last_30_days: "Last 30 days" - upload_files: "Upload files" - uploads_in_progress: "Cannot submit while uploads are in progress" - sentiments: - dashboard: - title: "Sentiment" - sidebar: - overview: "Sentiment overview" - analysis: "Sentiment analysis" - sentiment_analysis: - share_chart: "Copy link to chart" - filter_types: - all: "All" - positive: "Positive" - neutral: "Neutral" - negative: "Negative" - group_types: - category: "Category" - tag: "Tag" - table: - sentiment: "Sentiment" - total_count: "Total" - - summarization: - chat: - title: "Summarize messages" - description: "Select an option below to summarize the conversation sent during the desired timeframe." - summarize: "Summarize" - since: - one: "Last hour" - other: "Last %{count} hours" - topic: - title: "Topic summary" - close: "Close summary panel" - topic_list_layout: - button: - compact: "Compact" - expanded: "Expanded" - expanded_description: "with AI summaries" - - discobot_discoveries: - main_title: "Discobot discoveries" - regular_results: "Topics" - tell_me_more: "Tell me more..." - continue_convo: "Continue conversation..." - loading_convo: "Loading conversation" - collapse: "Collapse" - timed_out: "Discobot couldn't find any discoveries. Something went wrong." - user_setting: "Enable search discoveries" - tooltip: - header: "AI powered search" - content: "Natural language search powered by %{model}" - actions: - info: "How does it work?" - disable: "Disable" - - user_preferences: - empty: "There are no relevant settings available at this time" - review: - types: - reviewable_ai_post: - title: "AI-Flagged post" - reviewable_ai_chat_message: - title: "AI-Flagged chat message" diff --git a/config/locales/client.en_GB.yml b/config/locales/client.en_GB.yml deleted file mode 100644 index c2a0d3d6..00000000 --- a/config/locales/client.en_GB.yml +++ /dev/null @@ -1,22 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -en_GB: - js: - discourse_automation: - scriptables: - llm_report: - fields: - categories: - label: "Categories" - discourse_ai: - usage: - summary: "Summary" - ai_persona: - description: "Description" - tools: - description: "Description" - summary: "Summary" diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml deleted file mode 100644 index 2836dec0..00000000 --- a/config/locales/client.es.yml +++ /dev/null @@ -1,686 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -es: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Permite la búsqueda de IA" - stream_completion: "Permite la transmisión de realización de personas de IA" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "Emoción" - description: "La tabla muestra un recuento de los mensajes clasificados con una emoción determinada. Clasificados con el modelo 'SamLowe/roberta-base-go_emotions'." - reports: - filters: - sort_by: - label: "Ordenar por" - tag: - label: "Etiquetar" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Remitente" - description: "El usuario que enviará el informe" - receivers: - label: "Receptores" - description: "Los usuarios que recibirán el informe (los correos electrónicos se enviarán directamente, los nombres de usuario se enviarán por MP)" - topic_id: - label: "ID del tema" - description: "El ID del tema donde se publicará el informe" - title: - label: "Título" - description: "El título del informe" - days: - label: "Días" - description: "El plazo del informe" - offset: - label: "Compensación" - description: "Cuando realices pruebas, es posible que quieras ejecutar el informe históricamente, utiliza la compensación para iniciar el informe en una fecha anterior" - instructions: - label: "Instrucciones" - description: "Las instrucciones proporcionadas al modelo de lenguaje grande" - sample_size: - label: "Tamaño de la muestra" - description: "El número de publicaciones que se van a muestrear para el informe" - tokens_per_post: - label: "Tokens por publicación" - description: "La cantidad de tokens LLM que se usarán por publicación" - model: - label: "Modelo" - description: "LLM a utilizar para la generación de informes" - categories: - label: "Categorías" - description: "Filtrar temas solo para esta categoría" - tags: - label: "Etiquetas" - description: "Filtrar temas solo para estas etiquetas" - exclude_tags: - label: "Excluir etiquetas" - description: "Excluir temas con estas etiquetas" - exclude_categories: - label: "Excluir categorías" - description: "Excluir temas con estas categorías" - allow_secure_categories: - label: "Permitir categorías seguras" - description: "Permitir que se genere el informe para temas en categorías seguras" - suppress_notifications: - label: "Suprimir notificaciones" - description: "Suprime las notificaciones que pueda generar el informe transformando a contenido. Esto reasignará las menciones y los enlaces internos." - debug_mode: - label: "Modo de depuración" - description: "Activa el modo de depuración para ver la entrada y salida brutas del LLM" - priority_group: - label: "Grupo prioritario" - description: "Priorizar el contenido de este grupo en el informe" - temperature: - label: "Temperatura" - top_p: - label: "Top P" - llm_tool_triage: - fields: - model: - label: "Modelo" - llm_triage: - fields: - system_prompt: - label: "Aviso del sistema" - description: "El aviso que se utilizará para el triaje, asegúrate de que responda con una sola palabra que puedas utilizar para desencadenar la acción" - max_post_tokens: - label: "Máximo de tokens de publicación" - description: "El número máximo de tokens a escanear utilizando el triaje LLM" - stop_sequences: - label: "Detener secuencias" - description: "Ordena al modelo que detenga la generación de tokens cuando llegue a uno de estos valores" - search_for_text: - label: "Buscar texto" - description: "Si aparece el siguiente texto en la respuesta LLM, aplicar estas acciones" - category: - label: "Categoría" - description: "Categoría para aplicar al tema" - tags: - label: "Etiquetas" - description: "Etiquetas para aplicar al tema" - canned_reply: - label: "Responder" - description: "Texto sin formato de respuesta enlatada para publicar sobre el tema" - canned_reply_user: - label: "Responder al usuario" - description: "Nombre de usuario del usuario para publicar la respuesta enlatada" - hide_topic: - label: "Ocultar tema" - description: "Hacer que el tema no sea visible para el público si se activa" - flag_type: - label: "Tipo de denuncia" - description: "Tipo de denuncia que se aplicará a la publicación (spam o simplemente remitir para su revisión)" - flag_post: - label: "Denunciar publicación" - description: "Denuncia la publicación (como spam o para revisión)" - include_personal_messages: - label: "Incluir mensajes personales" - description: "También escanea y clasifica los mensajes personales" - model: - label: "Modelo" - description: "Modelo lingüístico utilizado para el triaje" - temperature: - label: "Temperatura" - discourse_ai: - title: "IA" - features: - back: "Volver" - disabled: "(desactivado)" - groups: "Grupos:" - no_persona: "No establecido" - no_groups: "Ninguno" - edit: "Editar" - expand_list: - one: "(%{count} más)" - other: "(%{count} más)" - collapse_list: "(mostrar menos)" - filters: - all: "Todos" - reset: "Restablecer" - search: - name: "Buscar" - embeddings: - name: "Incrustaciones" - ai_helper: - name: "Ayudante" - proofread: Corregir el texto - explain: "Explicar" - smart_dates: "Fechas inteligentes" - markdown_tables: "Generar tabla Markdown" - custom_prompt: "Instruccón personalizada" - spam: - name: "Spam" - description: "Identifica el correo no deseado potencial utilizando el LLM seleccionado y lo marca para que los moderadores del sitio lo inspeccionen en la cola de revisión" - modals: - select_option: "Selecciona una opción..." - spam: - short_title: "Spam" - title: "Configurar el manejo de correo no deseado" - select_llm: "Seleccionar LLM" - custom_instructions: "Instrucciones personalizadas" - custom_instructions_help: "Instrucciones personalizadas específicas de tu sitio para ayudar a guiar a la IA en la identificación del correo no deseado, por ejemplo: «Sé más agresivo a la hora de escanear los mensajes que no estén en inglés»." - last_seven_days: "Últimos 7 días" - scanned_count: "Publicaciones escaneadas" - false_positives: "Denunciado incorrectamente" - false_negatives: "Correo no deseado perdido" - spam_detected: "Correo no deseado detectado" - custom_instructions_placeholder: "Instrucciones específicas del sitio para que la IA ayude a identificar el correo no deseado con mayor precisión" - enable: "Activar" - spam_tip: "La detección de correo no deseado mediante IA escaneará los 3 primeros mensajes de todos los usuarios nuevos en temas públicos. Los marcará para su revisión y bloqueará a los usuarios si es probable que sean correo no deseado." - settings_saved: "Ajustes guardados" - spam_description: "Identifica el correo no deseado potencial utilizando el LLM seleccionado y lo marca para que los moderadores del sitio lo inspeccionen en la cola de revisión" - no_llms: "No hay LLM disponibles" - test_button: "Prueba..." - save_button: "Guardar cambios" - test_modal: - title: "Probar la detección de correo no deseado" - post_url_label: "URL o ID de la publicación" - post_url_placeholder: "https://tu-foro.com/t/topic/123/4 o ID de publicación" - result: "Resultado" - scan_log: "Registro de escaneo" - run: "Realizar prueba" - spam: "Spam" - not_spam: "No es correo no deseado" - stat_tooltips: - incorrectly_flagged: "Elementos que el bot de IA marcó como correo no deseado en los que los moderadores no estaban de acuerdo" - missed_spam: "Elementos denunciados por la comunidad como correo no deseado que no fueron detectados por el bot de IA, con los que los moderadores estaban de acuerdo" - errors: - scan_not_admin: - message: "Advertencia: el escaneo de correo no deseado no funcionará correctamente porque la cuenta de escaneo de correo no deseado no es un administrador" - action: "Corregir" - resolved: "¡Se ha resuelto el error!" - usage: - short_title: "Uso" - summary: "Resumen" - total_tokens: "Tokens totales" - tokens_over_time: "Tokens a lo largo del tiempo" - features_breakdown: "Uso por característica" - feature: "Característica" - usage_count: "Recuento de usos" - model: "Modelo" - models_breakdown: "Uso por modelo" - users_breakdown: "Uso por usuario" - all_features: "Todas las características" - all_models: "Todos los modelos" - username: "Nombre de usuario" - total_requests: "Peticiones totales" - request_tokens: "Tokens de solicitud" - response_tokens: "Tokens de respuesta" - net_request_tokens: "Tokens de solicitud de red" - cached_tokens: "Tokens almacenados en caché" - cached_request_tokens: "Tokens de solicitud almacenados en caché" - no_users: "No se han encontrado datos de uso del usuario" - no_models: "No se han encontrado datos de uso del modelo" - no_features: "No se han encontrado datos de uso de características" - subheader_description: "Los tokens son las unidades básicas que utilizan los LLM para comprender y generar texto, los datos de uso pueden afectar a los costes" - stat_tooltips: - total_requests: "Todas las solicitudes realizadas a los LLM a través de Discourse" - total_tokens: "Todos los tokens utilizados al solicitar un LLM" - request_tokens: "Tokens utilizados cuando el LLM intenta comprender lo que dices" - response_tokens: "Tokens utilizados cuando el LLM responde a tu consulta" - cached_tokens: "Tokens de solicitud previamente procesados que el LLM reutiliza para optimizar el rendimiento y el coste" - periods: - last_day: "Últimas 24 horas" - last_week: "Última semana" - last_month: "Último mes" - custom: "Personalizado..." - ai_persona: - ai_tools: "Herramientas" - tool_strategies: - all: "Aplicar a todas las respuestas" - replies: - one: "Aplicar solo a la primera respuesta" - other: "Aplicar a las primeras %{count}} respuestas" - back: "Atrás" - name: "Nombre" - edit: "Editar" - export: "Exportar" - description: "Descripción" - no_llm_selected: "No se seleccionó ningún modelo de idioma" - max_context_posts: "Número máximo de publicaciones contextuales" - max_context_posts_help: "El número máximo de mensajes a utilizar como contexto para la IA cuando responda a un usuario. (vacío por defecto)" - vision_enabled: Visión activada - vision_enabled_help: Si está activada, la IA intentará comprender las imágenes que los usuarios publiquen en el tema, depende del modelo que se utilice para soportar la visión. Compatible con los últimos modelos de Anthropic, Google y OpenAI. - vision_max_pixels: Tamaño de imagen admitido - vision_max_pixel_sizes: - low: 'Baja calidad: más barato (256x256)' - medium: Calidad media (512x512) - high: 'Alta calidad: más lento (1024x1024)' - tool_details: Mostrar detalles de la herramienta - tool_details_help: Mostrará a los usuarios finales detalles sobre qué herramientas ha activado el modelo de lenguaje. - mentionable: Permitir menciones - mentionable_help: Si está activada, los usuarios de los grupos permitidos pueden mencionar a este usuario en sus mensajes, y la IA responderá como esta persona. - user: Usuario - create_user: Crear usuario - create_user_help: Opcionalmente, puedes adjuntar un usuario a esta persona. Si lo haces, la IA utilizará a este usuario para responder a las solicitudes. - default_llm: Modelo lingüístico por defecto - default_llm_help: El modelo de idioma por defecto que se utilizará para esta persona. Obligatorio si deseas mencionar a la persona en publicaciones públicas. - question_consolidator_llm: Modelo lingüístico para el consolidador de preguntas - question_consolidator_llm_help: El modelo lingüístico a utilizar para el consolidador de preguntas, puedes elegir un modelo menos potente para ahorrar costes. - system_prompt: Aviso del sistema - forced_tool_strategy: Estrategia de herramienta forzada - allow_chat_direct_messages: "Permitir mensajes directos de chat" - allow_chat_direct_messages_help: "Si se activa, los usuarios de los grupos permitidos pueden enviar mensajes directos a esta persona." - allow_chat_channel_mentions: "Permitir menciones en el canal de chat" - allow_chat_channel_mentions_help: "Si se activa, los usuarios de los grupos permitidos pueden mencionar a esta persona en los canales de chat." - allow_personal_messages: "Permitir mensajes personales" - allow_personal_messages_help: "Si se activa, los usuarios de los grupos permitidos pueden enviar mensajes personales a esta persona." - allow_topic_mentions: "Permitir menciones en temas" - allow_topic_mentions_help: "Si se activa, los usuarios de los grupos permitidos pueden mencionar a esta persona en los temas." - force_default_llm: "Utilizar siempre el modelo de idioma por defecto" - save: "Guardar" - saved: "Persona guardada" - enabled: "¿Activado?" - tools: "Herramientas habilitadas" - forced_tools: "Herramientas forzadas" - allowed_groups: "Grupos permitidos" - confirm_delete: "¿Seguro que quieres eliminar esta persona?" - new: "Nueva persona" - no_personas: "Aún no has creado ninguna persona" - title: "Personas" - short_title: "Personas" - delete: "Eliminar" - temperature: "Temperatura" - temperature_help: "Temperatura que se utilizará para el LLM. Aumentar para aumentar la creatividad (dejar vacío para utilizar el valor por defecto del modelo, generalmente un valor de 0,0 a 2,0)" - top_p: "Top P" - top_p_help: "Top P a utilizar para el LLM, aumentar para aumentar la aleatoriedad (dejar vacío para utilizar el modelo por defecto, generalmente un valor de 0,0 a 1,0)" - priority: "Prioridad" - priority_help: "Las personas prioritarias se muestran a los usuarios en la parte superior de la lista de personas. Si varias personas tienen prioridad, se ordenarán alfabéticamente." - tool_options: "Opciones de herramientas" - rag_conversation_chunks: "Buscar fragmentos de conversación" - rag_conversation_chunks_help: "El número de fragmentos a utilizar para las búsquedas del modelo RAG. Aumentar para incrementar la cantidad de contexto que puede utilizar la IA." - persona_description: "Las personas son una potente característica que te permite personalizar el comportamiento del motor de IA en tu foro de Discourse. Actúan como un «mensaje del sistema» que guía las respuestas e interacciones de la IA, ayudando a crear una experiencia de usuario más personalizada y atractiva." - response_format: - open_modal: "Editar" - modal: - key_title: "Clave" - filters: - reset: "Restablecer" - rag: - options: - rag_chunk_tokens: "Cargar tokens de fragmentos" - rag_chunk_tokens_help: "El número de tokens a utilizar para cada fragmento en el modelo RAG. Aumentar para incrementar la cantidad de contexto que puede utilizar la IA. (Al cambiar se reindexarán todas las cargas)" - rag_chunk_overlap_tokens: "Cargar tokens de solapamiento de fragmentos" - rag_chunk_overlap_tokens_help: "El número de tokens a solapar entre fragmentos en el modelo RAG. (al cambiar se reindexarán todas las cargas)" - show_indexing_options: "Mostrar opciones de carga" - hide_indexing_options: "Ocultar opciones de carga" - uploads: - title: "Cargas" - button: "Añadir archivos" - filter: "Filtrar cargas" - indexed: "Indexado" - indexing: "Indexando" - uploaded: "Listo para ser indexado" - uploading: "Subiendo..." - remove: "Eliminar carga" - tools: - back: "Atrás" - short_title: "Herramientas" - export: "Exportar" - no_tools: "Aún no has creado ninguna herramienta" - name: "Nombre" - new: "Nueva herramienta" - description: "Descripción" - description_help: "Una descripción clara de la finalidad de la herramienta para el modelo lingüístico" - subheader_description: "Las herramientas amplían las capacidades de los robots de IA con funciones JavaScript definidas por el usuario." - summary: "Resumen" - summary_help: "Resumen de la finalidad de las herramientas que se mostrará a los usuarios finales" - script: "Script" - parameters: "Parámetros" - save: "Guardar" - remove_parameter: "Eliminar" - parameter_required: "Obligatorio" - parameter_enum: "Enumeración" - parameter_name: "Nombre del parámetro" - parameter_description: "Descripción del parámetro" - enum_value: "Valor de enumeración" - add_enum_value: "Añadir valor de enumeración" - edit: "Editar" - test: "Realizar prueba" - delete: "Eliminar" - saved: "Herramienta guardada" - confirm_delete: "¿Seguro que quieres eliminar esta herramienta?" - test_modal: - title: "Probar la herramienta de IA" - run: "Realizar prueba" - result: "Resultado de la prueba" - llms: - short_title: "LLM" - no_llms: "Aún no hay LLM" - new: "Nuevo modelo" - display_name: "Nombre" - name: "ID del modelo" - provider: "Proveedor" - tokenizer: "Tokenizador" - url: "URL del servicio que aloja el modelo" - api_key: "Clave API del servicio que aloja el modelo" - enabled_chat_bot: "Permitir selector de bot de IA" - vision_enabled: "Visión activada" - ai_bot_user: "Usuario de bot de IA" - save: "Guardar" - edit: "Editar" - saved: "Modelo LLM guardado" - back: "Atrás" - confirm_delete: '¿Seguro que quieres eliminar este modelo?' - delete: Eliminar - seeded_warning: "Este modelo está preconfigurado en tu web y no se puede editar." - quotas: - title: "Cuotas de uso" - add_title: "Crear nueva cuota" - group: "Grupo" - max_tokens: "Máximo de tokens" - max_usages: "Máximo de usos" - duration: "Duración" - confirm_delete: "¿Seguro que quieres eliminar esta cuota?" - add: "Añadir cuota" - durations: - hour: "1 hora" - six_hours: "6 horas" - day: "24 horas" - week: "7 días" - custom: "Personalizado..." - hours: "horas" - max_tokens_help: "Número máximo de tokens (palabras y caracteres) que cada usuario de este grupo puede utilizar dentro de la duración especificada. Los tokens son las unidades que utilizan los modelos de IA para procesar texto: aproximadamente 1 token = 4 caracteres o 3/4 de una palabra." - max_usages_help: "Número máximo de veces que cada usuario de este grupo puede utilizar el modelo de IA dentro de la duración especificada. Esta cuota se controla por usuario individual, no se comparte con todo el grupo." - usage: - ai_bot: "Bot de IA" - ai_helper: "Ayudante" - ai_persona: "Persona (%{persona})" - ai_summarization: "Resumir" - ai_embeddings_semantic_search: "Búsqueda de IA" - ai_spam: "Spam" - in_use_warning: - one: "Este modelo lo utiliza actualmente %{settings}. Si está mal configurado, la característica no funcionará como se espera." - other: "Este modelo lo utilizan actualmente las siguientes %{settings}. Si está mal configurado, las características no funcionarán como se espera." - model_description: - none: "Ajustes generales que funcionan para la mayoría de los modelos lingüísticos" - anthropic-claude-opus-4-0: "El modelo antrópico más inteligente" - anthropic-claude-3-5-haiku-latest: "Rápido y rentable" - google-gemini-2-5-flash: "Ligero, rápido y rentable con razonamiento multimodal" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Modelo multilingüe, ligero y eficaz" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Potente modelo polivalente" - mistral-mistral-large-latest: "El modelo más potente de Mistral" - mistral-pixtral-large-latest: "El modelo más potente de Mistral con capacidad de visión" - preseeded_model_description: "Modelo de código abierto preconfigurado que utiliza %{model}" - configured: - title: "LLM configurados" - preconfigured_llms: "Selecciona tu LLM" - preconfigured: - title_no_llms: "Selecciona una plantilla para empezar" - title: "Plantillas LLM no configuradas" - description: "Los LLM (Large Language Models) son herramientas de IA optimizadas para tareas como resumir contenidos, generar informes, automatizar las interacciones con los clientes y facilitar la moderación y los comentarios en los foros" - fake: "Configuración manual" - button: "Configurar" - next: - title: "Siguiente" - tests: - title: "Realizar prueba" - running: "Ejecutando prueba..." - success: "Éxito!" - failure: "Al intentar contactar con la modelo, se devolvió este error: %{error}" - hints: - name: "Incluimos esto en la llamada a la API para especificar qué modelo utilizaremos" - vision_enabled: "Si está activada, la IA intentará comprender las imágenes. Depende del modelo utilizado que soporte la visión. Compatible con los últimos modelos de Anthropic, Google y OpenAI." - enabled_chat_bot: "Si se activa, los usuarios pueden seleccionar este modelo al crear MPs con el bot de IA" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Personalizado" - provider_fields: - access_key_id: "ID de la clave de acceso a AWS Bedrock" - region: "Región de AWS Bedrock" - organization: "ID de organización de OpenAI opcional" - disable_system_prompt: "Desactivar el mensaje del sistema en las instrucciones" - enable_native_tool: "Activar la compatibilidad con herramientas nativas" - disable_native_tools: "Desactivar el soporte de herramientas nativas (usar herramientas basadas en XML)" - provider_order: "Orden de proveedores (lista delimitada por comas)" - provider_quantizations: "Orden de las cuantificaciones de los proveedores (lista delimitada por comas, por ejemplo: fp16, fp8)" - disable_streaming: "Desactiva las finalizaciones de streaming (convierte las peticiones de streaming en no streaming)" - related_topics: - title: "Temas relacionados" - pill: "Relacionados" - ai_helper: - title: "Sugerir cambios usando IA" - description: "Elige una de las opciones siguientes y la IA te propondrá una nueva versión del texto." - selection_hint: "Sugerencia: También puedes seleccionar una parte del texto antes de abrir el asistente para reescribir solo eso." - suggest: "Sugerir con IA" - suggest_errors: - too_many_tags: - one: "Solo puedes tener hasta %{count} etiqueta" - other: "Solo puedes tener hasta %{count} etiquetas" - no_suggestions: "No hay sugerencias disponibles" - missing_content: "Introduce algún contenido para generar sugerencias." - context_menu: - trigger: "Preguntar a la IA" - loading: "La IA está generando" - cancel: "Cancelar" - confirm: "Confirmar" - discard: "Descartar" - changes: "Ediciones sugeridas" - custom_prompt: - title: "Instruccón personalizada" - placeholder: "Introduzca un aviso personalizado..." - submit: "Enviar instrucción" - translate_prompt: "Traducir a %{language}" - post_options_menu: - trigger: "Pregúntale a la IA" - title: "Pregúntale a la IA" - loading: "La IA está generando" - close: "Cerrar" - copy: "Copiar" - copied: "¡Copiado!" - cancel: "Cancelar" - insert_footnote: "Añadir nota al pie de página" - footnote_disabled: "Inserción automática desactivada, pulsa el botón copiar y edítalo manualmente" - footnote_credits: "Explicación por IA" - fast_edit: - suggest_button: "Sugerir edición" - thumbnail_suggestions: - title: "Miniaturas sugeridas" - select: "Seleccionar" - selected: "Seleccionado" - image_caption: - button_label: "Subtítulo con IA" - generating: "Generando subtítulo..." - credits: "Subtitulado por IA" - save_caption: "Guardar" - automatic_caption_setting: "Activar subtítulos automáticos" - automatic_caption_loading: "Subtitulando imágenes..." - automatic_caption_dialog: - prompt: "Esta publicación contiene imágenes sin subtítulos. ¿Te gustaría activar los subtítulos automáticos en las imágenes subidas? (Puedes cambiarlo en tus preferencias más adelante)" - confirm: "Activar" - cancel: "No volver a preguntar" - no_content_error: "Añade contenido primero para realizar acciones de IA sobre él" - reviewables: - model_used: "Modelo utilizado:" - accuracy: "Precisión:" - embeddings: - short_title: "Incrustaciones" - new: "Nueva incrustación" - back: "Atrás" - save: "Guardar" - saved: "Configuración de incrustación guardada" - delete: "Eliminar" - confirm_delete: '¿Seguro que quieres eliminar esta configuración de incrustación?' - empty: "Aún no has configurado incrustaciones" - presets: "Selecciona un preajuste..." - configure_manually: "Configurar manualmente" - edit: "Editar" - seeded_warning: "Esto está preconfigurado en tu web y no se puede editar." - tests: - title: "Realizar prueba" - running: "Ejecutando prueba..." - success: "¡Éxito!" - failure: "El intento de generar una incrustación dio como resultado: %{error}" - hints: - dimensions_warning: "Una vez guardado, este valor no se puede cambiar." - matryoshka_dimensions: "Define el tamaño de las incrustaciones anidadas utilizadas para la representación jerárquica o en varias capas de los datos, de forma similar a como encajan los muñecos anidados unos dentro de otros." - sequence_length: "El número máximo de tokens que se pueden procesar a la vez al crear incrustaciones o gestionar una consulta." - distance_function: "Determina cómo se calcula la similitud entre las incrustaciones, utilizando la distancia del coseno (que mide el ángulo entre los vectores) o el producto interior negativo (que mide el solapamiento de los valores de los vectores)." - display_name: "Nombre" - provider: "Proveedor" - url: "URL del servicio de incrustación" - api_key: "Clave API del servicio de incrustación" - tokenizer: "Tokenizador" - dimensions: "Dimensiones de incrustación" - max_sequence_length: "Longitud de la secuencia" - embed_prompt: "Incrustar aviso" - search_prompt: "Buscar aviso" - matryoshka_dimensions: "Dimensiones de la matrioska" - distance_function: "Función de distancia" - distance_functions: - "<#>": "Producto interno negativo" - <=>: "Distancia del coseno" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Personalizado" - provider_fields: - model_name: "Nombre del modelo" - semantic_search: "Temas (semánticos)" - semantic_search_loading: "Buscando más resultados usando IA" - semantic_search_results: - toggle: "Mostrando %{count} resultados encontrados usando IA" - toggle_hidden: "Ocultando %{count} resultados encontrados usando IA" - none: "Lo sentimos, nuestra búsqueda mediante IA no ha encontrado temas coincidentes" - new: "Pulsa «Buscar» para empezar a buscar nuevos resultados con IA" - unavailable: "Resultados de IA no disponibles" - semantic_search_tooltips: - results_explanation: "Cuando esté activada, se añadirán resultados de búsqueda de IA adicionales a continuación." - invalid_sort: "Los resultados de la búsqueda deben ordenarse por Relevancia para mostrar resultados de IA" - semantic_search_unavailable_tooltip: "Los resultados de la búsqueda deben ordenarse por Relevancia para mostrar resultados de IA" - ai_generated_result: "Resultado de búsqueda encontrado mediante IA" - quick_search: - suffix: "en todos los temas y publicaciones con IA" - ai_artifact: - expand_view_label: "Ampliar vista" - collapse_view_label: "Salir de la pantalla completa (ESC o botón Atrás)" - click_to_run_label: "Ejecutar Artefacto" - ai_bot: - llm: "Modelo" - pm_warning: "Los moderadores supervisan periódicamente los mensajes del chatbot de IA." - cancel_streaming: "Detener respuesta" - default_pm_prefix: "[MP de bot de IA sin título]" - shortcut_title: "Iniciar un MP con un bot de IA" - share: "Copiar conversación de IA" - conversation_shared: "Conversación copiada" - debug_ai: "Ver solicitud y respuesta de IA sin procesar" - debug_ai_modal: - title: "Ver la interacción con la IA" - copy_request: "Copiar solicitud" - copy_response: "Copiar respuesta" - request_tokens: "Tokens de solicitud:" - response_tokens: "Tokens de respuesta:" - request: "Solicitud" - response: "Respuesta" - next_log: "Siguiente" - previous_log: "Anterior" - share_full_topic_modal: - title: "Compartir la conversación públicamente" - share: "Compartir y copiar enlace" - update: "Actualizar y copiar enlace" - delete: "Eliminar compartir" - share_ai_conversation: - name: "Compartir conversación de IA" - title: "Compartir esta conversación de IA públicamente" - invite_ai_conversation: - button: "Invitar" - ai_label: "IA" - ai_title: "Conversación con IA" - share_modal: - title: "Copiar conversación de IA" - copy: "Copiar" - context: "Interacciones para compartir:" - share_tip: "También puedes compartir toda la conversación" - bot_names: - fake: "Bot de prueba falso" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Hoy" - last_7_days: "Últimos 7 días" - last_30_days: "Últimos 30 días" - sentiments: - dashboard: - title: "Sentiment" - sentiment_analysis: - filter_types: - all: "Todo" - positive: "Positivo" - neutral: "Neutro" - negative: "Negativo" - group_types: - category: "Categoría" - tag: "Etiquetar" - table: - sentiment: "Sentiment" - total_count: "Total" - summarization: - chat: - title: "Resumir mensajes" - description: "Selecciona una opción a continuación para resumir la conversación enviada durante el periodo de tiempo deseado." - summarize: "Resumir" - since: - one: "Última hora" - other: "Últimas %{count} horas" - topic: - title: "Resumen del tema" - close: "Cerrar panel de resumen" - topic_list_layout: - button: - compact: "Compacto" - expanded: "Ampliado" - expanded_description: "con resúmenes de IA" - discobot_discoveries: - regular_results: "Temas" - collapse: "Contraer" - tooltip: - actions: - disable: "Desactivar" - review: - types: - reviewable_ai_post: - title: "Publicación denunciada por IA" - reviewable_ai_chat_message: - title: "Mensaje de chat denunciado por IA" diff --git a/config/locales/client.et.yml b/config/locales/client.et.yml deleted file mode 100644 index 3bb6f7f0..00000000 --- a/config/locales/client.et.yml +++ /dev/null @@ -1,165 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -et: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Järjesta" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Teema ID" - title: - label: "Pealkiri" - categories: - label: "Liigid" - tags: - label: "Sildid" - llm_triage: - fields: - category: - label: "Foorum" - tags: - label: "Sildid" - canned_reply: - label: "Vasta" - discourse_ai: - features: - back: "Tagasi" - disabled: "(välja lülitatud)" - groups: "Rühmad:" - no_groups: "Pole" - edit: "Muuda" - expand_list: - one: "(veel %{count})" - other: "(veel %{count})" - filters: - all: "Kõik" - reset: "Lähtesta" - search: - name: "Otsi" - spam: - name: "Spämm" - modals: - select_option: "Vali võimalus..." - spam: - short_title: "Spämm" - enable: "Lülita sisse" - test_modal: - spam: "Spämm" - usage: - summary: "Kokkuvõte" - username: "Kasutajanimi" - total_requests: "Kokku päringuid" - ai_persona: - back: "Tagasi" - name: "Nimi" - edit: "Muuda" - export: "Ekspordi" - description: "Kirjeldus" - user: Kasutaja - save: "Salvesta" - enabled: "Sisse lülitatud?" - delete: "Kustuta" - response_format: - open_modal: "Muuda" - filters: - reset: "Lähtesta" - rag: - uploads: - title: "Üleslaadimised" - uploading: "Laen üles..." - tools: - back: "Tagasi" - export: "Ekspordi" - name: "Nimi" - description: "Kirjeldus" - summary: "Kokkuvõte" - save: "Salvesta" - remove_parameter: "Eemalda" - parameter_required: "Nõutud" - edit: "Muuda" - delete: "Kustuta" - llms: - display_name: "Nimi" - save: "Salvesta" - edit: "Muuda" - back: "Tagasi" - delete: Kustuta - quotas: - group: "Grupp" - durations: - hour: "1 tund" - six_hours: "6 tundi" - day: "24 tundi" - week: "7 päeva" - hours: "tundi" - usage: - ai_spam: "Spämm" - next: - title: "Järgmine" - tests: - success: "Korras!" - providers: - google: "Google" - fake: "Individuaalne" - ai_helper: - context_menu: - cancel: "Tühista" - post_options_menu: - close: "Sulge" - copy: "Kopeeri" - copied: "Kopeeritud!" - cancel: "Tühista" - image_caption: - save_caption: "Salvesta" - automatic_caption_dialog: - confirm: "Lülita sisse" - embeddings: - back: "Tagasi" - save: "Salvesta" - delete: "Kustuta" - edit: "Muuda" - tests: - success: "Korras!" - display_name: "Nimi" - providers: - google: "Google" - fake: "Individuaalne" - ai_bot: - debug_ai_modal: - request: "Päring" - response: "Vastus" - next_log: "Järgmine" - previous_log: "Eelmine" - invite_ai_conversation: - button: "Kutsu" - share_modal: - copy: "Kopeeri" - conversations: - today: "Täna" - sentiments: - sentiment_analysis: - filter_types: - all: "Kõik" - neutral: "Neutraalne" - group_types: - category: "Foorum" - table: - total_count: "Kokku" - discobot_discoveries: - regular_results: "Teemasid" - collapse: "Ahenda" - tooltip: - actions: - disable: "Lülita välja" diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml deleted file mode 100644 index cc7397d9..00000000 --- a/config/locales/client.fa_IR.yml +++ /dev/null @@ -1,235 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -fa_IR: - admin_js: - admin: - site_settings: - categories: - discourse_ai: "هوش مصنوعی دیسکورس" - dashboard: - reports: - filters: - sort_by: - label: "مرتب سازی بر اساس" - tag: - label: "برچسب" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "فرستنده" - topic_id: - label: "شناسه موضوع" - title: - label: "عنوان" - days: - label: "روز" - model: - label: "مدل" - categories: - label: "دسته‌بندی‌ها" - tags: - label: "برچسب‌ها" - suppress_notifications: - label: "توقف آگاه‌سازی‌ها" - debug_mode: - label: "حالت اشکال زدایی" - llm_tool_triage: - fields: - model: - label: "مدل" - llm_triage: - fields: - search_for_text: - label: "جستجوی متن" - category: - label: "دسته‌بندی" - tags: - label: "برچسب‌ها" - canned_reply: - label: "پاسخ" - canned_reply_user: - label: "پاسخ کاربر" - model: - label: "مدل" - discourse_ai: - title: "هوش مصنوعی" - features: - back: "بازگشت" - disabled: "(غیرفعال)" - groups: "گروه‌ها:" - no_persona: "تنظیم نشده" - no_groups: "هیچ کدام" - edit: "ویرایش" - expand_list: - one: "(%{count} مورد دیگر)" - other: "(%{count} مورد دیگر)" - collapse_list: "(نمایش کمتر)" - filters: - all: "همه" - reset: "بازنشانی" - search: - name: "جستجو" - spam: - name: "هرز‌نامه" - modals: - select_option: "یک گزینه را انتخاب کنید..." - spam: - short_title: "هرز‌نامه" - last_seven_days: "۷ روز گذشته" - enable: "فعال کردن" - test_modal: - spam: "هرز‌نامه" - usage: - summary: "خلاصه" - model: "مدل" - username: "نام‌کاربری" - total_requests: "مجموع درخواست‌ها" - periods: - last_day: "۲۴ ساعت گذشته" - custom: "سفارشی..." - ai_persona: - back: "بازگشت" - name: "نام" - edit: "ویرایش" - export: "خروجی گرفتن" - description: "توضیح" - user: کاربر - save: "ذخیره" - enabled: "فعال شده؟" - delete: "حذف" - response_format: - open_modal: "ویرایش" - modal: - key_title: "کلید" - filters: - reset: "بازنشانی" - rag: - uploads: - title: "بارگذاری‌ها" - uploading: "در حال بار گذاری..." - tools: - back: "بازگشت" - export: "خروجی گرفتن" - name: "نام" - description: "توضیح" - summary: "خلاصه" - save: "ذخیره" - remove_parameter: "پاک کردن" - parameter_required: "مورد نیاز" - edit: "ویرایش" - delete: "حذف" - llms: - display_name: "نام" - save: "ذخیره" - edit: "ویرایش" - back: "بازگشت" - delete: حذف - quotas: - group: "گروه" - max_usages: "حداکثر استفاده" - duration: "مدت زمان" - durations: - hour: "۱ ساعت" - day: "۲۴ ساعت" - week: "۷ روزه" - custom: "سفارشی..." - hours: "ساعت" - usage: - ai_summarization: "خلاصه کنید" - ai_spam: "هرز‌نامه" - next: - title: "بعدی" - tests: - running: "در حال اجرای آزمایش..." - success: "موفقیت!" - failure: "در حال ارتباط با مدل این خطا رخ داد: %{error}" - providers: - google: "گوگل" - fake: "سفارشی" - ai_helper: - title: "پیشنهاد تغییرات با استفاده از هوش مصنوعی" - description: "یکی از گزینه‌های زیر را انتخاب کنید و هوش مصنوعی نسخه جدیدی از متن را به شما پیشنهاد می‌کند." - selection_hint: "نکته: شما همچنین می‌توانید بخشی از متن را قبل از باز کردن راهنما انتخاب کنید، تا فقط آن قسمت بازنویسی شود." - context_menu: - cancel: "انصراف" - confirm: "تایید" - discard: "حذف" - post_options_menu: - close: "بستن" - copy: "کپی" - copied: "کپی شد!" - cancel: "انصراف" - insert_footnote: "افزودن پاورقی" - footnote_credits: "توضیح توسط هوش مصنوعی" - thumbnail_suggestions: - select: "انتخاب" - image_caption: - save_caption: "ذخیره" - automatic_caption_dialog: - confirm: "فعال کردن" - reviewables: - model_used: "مدل مورد استفاده:" - accuracy: "دقت:" - embeddings: - back: "بازگشت" - save: "ذخیره" - delete: "حذف" - edit: "ویرایش" - tests: - running: "در حال اجرای آزمایش..." - success: "موفقیت!" - display_name: "نام" - providers: - google: "گوگل" - fake: "سفارشی" - provider_fields: - model_name: "نام مدل" - semantic_search: "موضوعات (معنایی)" - ai_bot: - llm: "مدل" - cancel_streaming: "توقف پاسخ" - conversation_shared: "گفتگو کپی شد" - debug_ai_modal: - request: "درخواست" - response: "پاسخ" - next_log: "بعدی" - previous_log: "پیشین" - invite_ai_conversation: - button: "دعوت" - ai_label: "هوش مصنوعی" - share_modal: - copy: "کپی" - bot_names: - gpt-4: "GPT-4" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - conversations: - today: "امروز" - last_7_days: "۷ روز گذشته" - last_30_days: "۳۰ روز گذشته" - sentiments: - sentiment_analysis: - filter_types: - all: "همه" - group_types: - category: "دسته‌بندی" - tag: "برچسب" - table: - total_count: "مجموع" - summarization: - chat: - summarize: "خلاصه کنید" - discobot_discoveries: - regular_results: "موضوعات" - collapse: "جمع کردن" - tooltip: - actions: - disable: "غیرفعال" diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml deleted file mode 100644 index 3e6e7229..00000000 --- a/config/locales/client.fi.yml +++ /dev/null @@ -1,686 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -fi: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Sallii tekoälyhaun" - stream_completion: "Sallii tekoälypersoonan valmistumisen suoratoiston" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "Tunne" - description: "Taulukossa luetellaan määritetyllä tunteella luokiteltujen viestien lukumäärä. Luokiteltu mallilla \"SamLowe/roberta-base-go_emotions\"." - reports: - filters: - sort_by: - label: "Järjestä" - tag: - label: "Tunniste" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Lähettäjä" - description: "Käyttäjä, joka lähettää raportin" - receivers: - label: "Vastaanottajat" - description: "Käyttäjät, jotka saavat raportin (sähköpostit lähetetään suorina sähköposteina, käyttäjätunnuksille lähetetään yksityisviesti)" - topic_id: - label: "Ketjun tunnus" - description: "Sen ketjun tunnus, johon raportti julkaistaan" - title: - label: "Otsikko" - description: "Raportin otsikko" - days: - label: "Päivät" - description: "Raportin aikaväli" - offset: - label: "Siirtymä" - description: "Testatessasi voi olla hyvä idea laatia raportti historiallisesti, käyttää siirtymää ja aloittaa raportti aikaisemmasta päivämäärästä" - instructions: - label: "Ohjeet" - description: "Suurelle kielimallille annetut ohjeet" - sample_size: - label: "Otoksen koko" - description: "Viestin määrä raportin otantaa varten" - tokens_per_post: - label: "Saneita viestiä kohden" - description: "Viestiä kohden käytettävien suuren kielimallin saneiden määrä" - model: - label: "Malli" - description: "Raportin luomiseen käytettävä LLM" - categories: - label: "Luokat" - description: "Suodata ketjut vain näihin alueisiin" - tags: - label: "Tunnisteet" - description: "Suodata ketjut vain näihin tunnisteisiin" - exclude_tags: - label: "Sulje pois tunnisteet" - description: "Sulje pois ketjut, joilla on näitä tunnisteita" - exclude_categories: - label: "Sulje pois alueita" - description: "Sulje pois näiden alueiden ketjut" - allow_secure_categories: - label: "Salli suojatut alueet" - description: "Salli raportin luominen suojattujen alueiden ketjuista" - suppress_notifications: - label: "Estä ilmoitukset" - description: "Estä ilmoitukset, joita raportti voi luoda muuttumalla sisällöksi. Tämä kartoittaa maininnat ja sisäiset linkit uudelleen." - debug_mode: - label: "Virheenkorjaustila" - description: "Ota virheenkorjaustila käyttöön nähdäksesi LLM:n raakasyötteen ja -tuotoksen" - priority_group: - label: "Prioriteettiryhmä" - description: "Priorisoi tämän ryhmän sisältö raportissa" - temperature: - label: "Lämpötila" - top_p: - label: "Top-p" - llm_tool_triage: - fields: - model: - label: "Malli" - llm_triage: - fields: - system_prompt: - label: "Järjestelmäkehote" - description: "Kehote, jota käytetään luokitteluun; varmista, että se vastaa yhdellä sanalla, jota voit käyttää toiminnon käynnistämiseen" - max_post_tokens: - label: "Viestin saneiden enimmäismäärä" - description: "Skannattavien saneiden enimmäismäärä LLM-luokittelulla" - stop_sequences: - label: "Pysäytysjaksot" - description: "Ohjeista mallia keskeyttämään saneiden luominen, kun jokin näistä arvoista saavutetaan" - search_for_text: - label: "Hae tekstiä" - description: "Jos suuren kielimallin vastauksessa näkyy seuraava teksti, tee nämä toimet" - category: - label: "Luokka" - description: "Ketjuun sovellettava alue" - tags: - label: "Tunnisteet" - description: "Ketjuun sovellettavat tunnisteet" - canned_reply: - label: "Vastaa" - description: "Ketjuun lähetettävän valmiin vastauksen raaka teksti" - canned_reply_user: - label: "Vastauskäyttäjä" - description: "Valmiin vastauksen lähettävän käyttäjän käyttäjätunnus" - hide_topic: - label: "Piilota ketju" - description: "Tee ketjusta näkymätön yleisölle, jos tämä laukaistaan" - flag_type: - label: "Lipun tyyppi" - description: "Viestiin sovellettavan lipun tyyppi (roskaposti tai yksinkertaisesti ilmoita käsiteltäväksi)" - flag_post: - label: "Liputa viesti" - description: "Liputtaa viestin (joko roskapostiksi tai käsiteltäväksi)" - include_personal_messages: - label: "Sisällytä yksityisviestit" - description: "Skannaa ja luokittele myös yksityisviestit" - model: - label: "Malli" - description: "Luokitteluun käytettävä kielimalli" - temperature: - label: "Lämpötila" - discourse_ai: - title: "Tekoäly" - features: - back: "Takaisin" - disabled: "(pois käytöstä)" - groups: "Ryhmät:" - no_persona: "Ei asetettu" - no_groups: "Ei valittu" - edit: "Muokkaa" - expand_list: - one: "(%{count} muuta)" - other: "(%{count} muuta)" - collapse_list: "(näytä vähemmän)" - filters: - all: "Kaikki" - reset: "Palauta" - search: - name: "Hae" - embeddings: - name: "Upotukset" - ai_helper: - name: "Apuri" - proofread: Oikolue teksti - explain: "Selitä" - smart_dates: "Älykkäät päivämäärät" - markdown_tables: "Luo markdown-taulukko" - custom_prompt: "Mukautettu kehote" - spam: - name: "Roskaposti" - description: "Tunnistaa mahdollisen roskapostin valitun suuren kielimallin avulla ja liputtaa sen sivuston valvojien tarkastettavaksi tarkastusjonoon" - modals: - select_option: "Valitse vaihtoehto..." - spam: - short_title: "Roskaposti" - title: "Määritä roskapostin käsittely" - select_llm: "Valitse LLM" - custom_instructions: "Mukautetut ohjeet" - custom_instructions_help: "Sivustoasi koskevat mukautetut ohjeet, jotka auttavat tekoälyä tunnistamaan roskapostia, esim. \"ole aggressiivisempi skannattaessa viestejä, jotka eivät ole englanninkielisiä\"." - last_seven_days: "Viimeiset 7 päivää" - scanned_count: "Skannatut viestit" - false_positives: "Liputettu väärin" - false_negatives: "Huomaamatta jäänyt roskaposti" - spam_detected: "Havaittu roskaposti" - custom_instructions_placeholder: "Sivustokohtaiset ohjeet, jotka auttavat tekoälyä tunnistamaan roskapostin tarkemmin" - enable: "Ota käyttöön" - spam_tip: "Tekoälyyn perustuva roskapostin tunnistus skannaa kaikkien uusien käyttäjien kolme ensimmäistä viestiä julkisissa ketjuissa. Se liputtaa ne käsiteltäväksi ja estää käyttäjät, jos ne ovat todennäköisesti roskapostia." - settings_saved: "Asetukset tallennettu" - spam_description: "Tunnistaa mahdollisen roskapostin valitun suuren kielimallin avulla ja liputtaa sen sivuston valvojien tarkastettavaksi tarkastusjonoon" - no_llms: "Suuria kielimalleja ei ole saatavilla" - test_button: "Testaa..." - save_button: "Tallenna muutokset" - test_modal: - title: "Testaa roskapostin tunnistusta" - post_url_label: "Vistin URL tai tunnus" - post_url_placeholder: "https://foorumisi.com/t/topic/123/4 tai viestin tunnus" - result: "Tulos" - scan_log: "Skannausloki" - run: "Suorita testi" - spam: "Roskaposti" - not_spam: "Ei roskapostia" - stat_tooltips: - incorrectly_flagged: "Kohteet, jotka tekoälybotti liputti roskapostiksi, mutta joista valvojat olivat eri mieltä" - missed_spam: "Yhteisön roskapostiksi liputtamaton kohteet, joita tekoälybotti ei havainnut ja joista valvojat olivat samaa mieltä" - errors: - scan_not_admin: - message: "Varoitus: roskapostin skannaus ei toimi oikein, koska roskapostin skannaustili ei ole ylläpitäjä" - action: "Korjaa" - resolved: "Virhe on korjattu!" - usage: - short_title: "Käyttö" - summary: "Yhteenveto" - total_tokens: "Saneet yhteensä" - tokens_over_time: "Saneet ajan myötä" - features_breakdown: "Käyttö ominaisuutta kohden" - feature: "Ominaisuus" - usage_count: "Käyttömäärä" - model: "Malli" - models_breakdown: "Käyttö mallia kohden" - users_breakdown: "Käyttö käyttäjää kohden" - all_features: "Kaikki ominaisuudet" - all_models: "Kaikki mallit" - username: "Käyttäjätunnus" - total_requests: "Pyyntöjä yhteensä" - request_tokens: "Pyynnön tokenit" - response_tokens: "Vastauksen tokenit" - net_request_tokens: "Nettopyyntösaneet" - cached_tokens: "Välimuistissa olevat saneet" - cached_request_tokens: "Välimuistiin tallennetut pyyntösaneet" - no_users: "Käyttäjien käyttötietoja ei löytynyt" - no_models: "Mallin käyttötietoja ei löytynyt" - no_features: "Ominaisuuden käyttötietoja ei löytynyt" - subheader_description: "Saneet ovat perusyksiköitä, joita suuret kielimallit käyttävät tekstin ymmärtämiseen ja luomiseen. Käyttötiedot voivat vaikuttaa kustannuksiin" - stat_tooltips: - total_requests: "Kaikki suurille kielimalleille Discoursen kautta tehdyt pyynnöt" - total_tokens: "Kaikki käytetyt saneet kehotteissa suurille kielimalleille" - request_tokens: "Käytetyt saneet, kun suuri kielimalli yrittää ymmärtää, mitä sanot" - response_tokens: "Käytetyt saneet, kun suuri kielimalli vastaa kehotteeseesi" - cached_tokens: "Aiemmin käsitellyt pyyntösaneet, joita suuri kielimalli käyttää uudelleen suorituskyvyn ja kustannusten optimoimiseksi" - periods: - last_day: "Viimeiset 24 tuntia" - last_week: "Viime viikko" - last_month: "Viime kuukausi" - custom: "Mukautettu..." - ai_persona: - ai_tools: "Työkalut" - tool_strategies: - all: "Käytä kaikkiin vastauksiin" - replies: - one: "Käytä vain ensimmäiseen vastaukseen" - other: "Käytä vain %{count} ensimmäiseen vastaukseen" - back: "Takaisin" - name: "Nimi" - edit: "Muokkaa" - export: "Vie" - description: "Kuvaus" - no_llm_selected: "Kielimallia ei ole valittu" - max_context_posts: "Kontekstiviestien enimmäismäärä" - max_context_posts_help: "Kontekstina käytettävien viestien enimmäismäärä, kun tekoäly vastaa käyttäjälle (käytä oletusta jättämällä tyhjäksi)" - vision_enabled: Näkö käytössä - vision_enabled_help: Jos tämä on käytössä, tekoäly yrittää ymmärtää kuvia, joita käyttäjät julkaisevat ketjussa, riippuen siitä, tukeeko käytettävä malli näköä. Anthropicin, Googlen ja OpenAI:n uusimmat mallit tukevat näköä. - vision_max_pixels: Tuettu kuvakoko - vision_max_pixel_sizes: - low: Heikko laatu – halvin (256x256) - medium: Keskilaatuinen (512x512) - high: Korkea laatu – hitain (1024x1024) - tool_details: Näytä työkalun tiedot - tool_details_help: Näyttää loppukäyttäjille tiedot siitä, mitkä työkalut kielimalli on laukaissut. - mentionable: Salli maininnat - mentionable_help: Jos tämä on käytössä, sallittujen ryhmien käyttäjät voivat mainita tämän käyttäjän viesteissä, tekoäly vastaa tänä persoonana. - user: Käyttäjä - create_user: Luo käyttäjä - create_user_help: Voit halutessasi liittää käyttäjän tähän persoonaan. Jos teet näin, tekoäly vastaa pyyntöihin käyttämällä tätä käyttäjää. - default_llm: Oletuskielimalli - default_llm_help: Tälle persoonalle käytettävä oletuskielimalli. Vaaditaan, jos haluat mainita persoonan julkisissa viesteissä. - question_consolidator_llm: Kielimalli kysymysten yhdistäjälle - question_consolidator_llm_help: Kysymysten yhdistäjälle käytettävä kielimalli, voit valita vähemmän tehokkaan mallin kustannusten säästämiseksi. - system_prompt: Järjestelmäkehote - forced_tool_strategy: Pakotetun työkalun strategia - allow_chat_direct_messages: "Salli chat-yksityisviestit" - allow_chat_direct_messages_help: "Jos tämä on käytössä, sallittujen ryhmien käyttäjät voivat lähettää yksityisviestejä tälle persoonalle." - allow_chat_channel_mentions: "Salli chat-kanavamaininnat" - allow_chat_channel_mentions_help: "Jos tämä on käytössä, sallittujen ryhmien käyttäjät voivat mainita tämän persoonan chat-kanavilla." - allow_personal_messages: "Salli yksityisviestit" - allow_personal_messages_help: "Jos tämä on käytössä, sallittujen ryhmien käyttäjät voivat lähettää yksityisviestejä tälle persoonalle." - allow_topic_mentions: "Salli ketjumaininnat" - allow_topic_mentions_help: "Jos tämä on käytössä, sallittujen ryhmien käyttäjät voivat mainita tämän persoonan ketjuissa." - force_default_llm: "Käytä aina oletuskielimallia" - save: "Tallenna" - saved: "Persoona tallennettu" - enabled: "Otettu käyttöön?" - tools: "Käytössä olevat työkalut" - forced_tools: "Pakotetut työkalut" - allowed_groups: "Sallitut ryhmät" - confirm_delete: "Oletko varma, että haluat poistaa tämän persoonan?" - new: "Uusi persoona" - no_personas: "Et ole vielä luonut persoonia" - title: "Persoonat" - short_title: "Persoonat" - delete: "Poista" - temperature: "Lämpötila" - temperature_help: "LLM:lle käytettävä lämpötila. Lisää luovuutta kasvattamalla arvoa (jätä tyhjäksi, jos haluat käyttää mallin oletusta, yleensä arvo välillä 0,0–2,0)" - top_p: "Top-p" - top_p_help: "LLM:lle käytettävä top-p, lisää satunnaisuutta kasvattamalla arvoa (jätä tyhjäksi, jos haluat käyttää mallin oletusta, yleensä arvo välillä 0,0–1,0)" - priority: "Prioriteetti" - priority_help: "Prioriteettipersoonat näytetään käyttäjille ensimmäisinä persoonaluettelossa. Jos useilla persoonilla on prioriteetti, ne järjestetään aakkosjärjestyksessä." - tool_options: "Työkaluasetukset" - rag_conversation_chunks: "Hakukeskustelulohkot" - rag_conversation_chunks_help: "RAG-mallin hauissa käytettävien lohkojen määrä. Lisää kontekstin määrää, jota tekoäly voi käyttää, kasvattamalla arvoa." - persona_description: "Personat ovat tehokas ominaisuus, jonka avulla voit mukauttaa tekoälymoduulin toimintaa Discourse-foorumillasi. Ne toimivat \"järjestelmäviestinä\", joka ohjaa tekoälyn vastauksia ja vuorovaikutusta ja auttaa luomaan personoidumman ja kiinnostavamman käyttökokemuksen." - response_format: - open_modal: "Muokkaa" - modal: - key_title: "Avain" - filters: - reset: "Palauta" - rag: - options: - rag_chunk_tokens: "Latauslohkotokenit" - rag_chunk_tokens_help: "Kullekin lohkolle käytettävien tokenien määrä RAG-mallissa. Lisää kontekstin määrää, jota tekoäly voi käyttää, kasvattamalla arvoa (muuttaminen indeksoi kaikki lataukset palvelimeen uudelleen)." - rag_chunk_overlap_tokens: "Latauslohkon päällekkäiset tokenit" - rag_chunk_overlap_tokens_help: "Päällekkäisten tokenien määrä lohkojen välillä RAG-mallissa (muuttaminen indeksoi kaikki lataukset palvelimeen uudelleen). " - show_indexing_options: "Näytä latausasetukset" - hide_indexing_options: "Piilota latausasetukset" - uploads: - title: "Lataukset" - button: "Lisää tiedostoja" - filter: "Suodata latauksia" - indexed: "Indeksoitu" - indexing: "Indeksointi" - uploaded: "Valmiina indeksoitavaksi" - uploading: "Ladataan..." - remove: "Poista lataus" - tools: - back: "Takaisin" - short_title: "Työkalut" - export: "Vie" - no_tools: "Et ole vielä luonut työkaluja" - name: "Nimi" - new: "Uusi työkalu" - description: "Kuvaus" - description_help: "Selkeä kuvaus työkalun tarkoituksesta kielimallissa" - subheader_description: "Työkalut laajentavat tekoälybottien ominaisuuksia käyttäjän määrittämillä JavaScript-toiminnoilla." - summary: "Yhteenveto" - summary_help: "Loppukäyttäjille näytettävä yhteenveto työkalujen tarkoituksesta" - script: "Skripti" - parameters: "Parametrit" - save: "Tallenna" - remove_parameter: "Poista" - parameter_required: "Pakollinen" - parameter_enum: "Enum" - parameter_name: "Parametrin nimi" - parameter_description: "Parametrin kuvaus" - enum_value: "Enum-arvo" - add_enum_value: "Lisää enum-arvo" - edit: "Muokkaa" - test: "Suorita testi" - delete: "Poista" - saved: "Työkalu tallennettu" - confirm_delete: "Oletko varma, että haluat poistaa tämän työkalun?" - test_modal: - title: "Testaa tekoälytyökalua" - run: "Suorita testi" - result: "Testin tulos" - llms: - short_title: "LLM:t" - no_llms: "LLM:iä ei ole vielä" - new: "Uusi malli" - display_name: "Nimi" - name: "Mallin tunnus" - provider: "Palveluntarjoaja" - tokenizer: "Tokenisoija" - url: "Mallia isännöivän palvelun URL-osoite" - api_key: "Mallia isännöivän palvelun API-avain" - enabled_chat_bot: "Salli tekoälybotin valitsin" - vision_enabled: "Näkö käytössä" - ai_bot_user: "Tekoälybottikäyttäjä" - save: "Tallenna" - edit: "Muokkaa" - saved: "LLM-malli tallennettu" - back: "Takaisin" - confirm_delete: Oletko varma, että haluat poistaa tämän mallin? - delete: Poista - seeded_warning: "Tämä malli on valmiiksi määritetty sivustollesi, eikä sitä voi muokata." - quotas: - title: "Käyttökiintiöt" - add_title: "Luo uusi kiintiö" - group: "Ryhmä" - max_tokens: "Saneiden enimmäismäärä" - max_usages: "Käyttökertoja enintään" - duration: "Kesto" - confirm_delete: "Oletko varma, että haluat poistaa tämän kiintiön?" - add: "Lisää kiintiö" - durations: - hour: "1 tunti" - six_hours: "6 tuntia" - day: "24 tuntia" - week: "7 päivää" - custom: "Mukautettu..." - hours: "tuntia" - max_tokens_help: "Saneiden (sanojen ja merkkien) enimmäismäärä, jonka kukin tämän ryhmän käyttäjä voi käyttää määritetyn keston aikana. Saneet ovat yksiköitä, joita tekoälymallit käyttävät tekstin käsittelyyn – noin 1 merkki = 4 merkkiä tai 3/4 sanasta." - max_usages_help: "Enimmäismäärä kertoja, jonka kukin tämän ryhmän käyttäjä voi käyttää tekoälymallia määritetyn ajan kuluessa. Tätä kiintiötä seurataan yksittäistä käyttäjää kohti, eikä sitä jaeta ryhmän kesken." - usage: - ai_bot: "Tekoälyrobotti" - ai_helper: "Apuri" - ai_persona: "Persoona (%{persona})" - ai_summarization: "Tee yhteenveto" - ai_embeddings_semantic_search: "Tekoälyhaku" - ai_spam: "Roskaposti" - in_use_warning: - one: "Tätä mallia käyttää tällä hetkellä %{settings}. Jos se on määritetty väärin, ominaisuus ei toimi odotetulla tavalla." - other: "Tätä mallia käyttää tällä hetkellä seuraavat: %{settings}. Jos se on määritetty väärin, ominaisuus ei toimi odotetulla tavalla. " - model_description: - none: "Yleiset asetukset, jotka toimivat useimmissa kielimalleissa" - anthropic-claude-opus-4-0: "Anthropicin älykkäin malli" - anthropic-claude-3-5-haiku-latest: "Nopea ja kustannustehokas" - google-gemini-2-5-flash: "Kevyt, nopea ja kustannustehokas multimodaalisella päättelyllä" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Tehokas kevyt monikielinen malli" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Tehokas monikäyttöinen malli" - mistral-mistral-large-latest: "Mistralin tehokkain malli" - mistral-pixtral-large-latest: "Mistralin tehokkain näkökykyinen malli" - preseeded_model_description: "Ennalta määritetty avoimen lähdekoodin malli, joka käyttää mallia %{model}" - configured: - title: "Määritetyt LLM:t" - preconfigured_llms: "Valitse LLM" - preconfigured: - title_no_llms: "Aloita valitsemalla malli" - title: "Määrittämättömät LLM-mallit" - description: "LLM:t (suuret kielimallit) ovat tekoälytyökaluja, jotka on optimoitu tehtäviin, kuten sisällön yhteenvedon laatimiseen sisällöstä, raporttien luomiseen, asiakasvuorovaikutusten automatisointiin sekä foorumin valvonnan ja tietokatsausten helpottamiseen" - fake: "Manuaalinen määritys" - button: "Määritä" - next: - title: "Seuraava" - tests: - title: "Suorita testi" - running: "Suoritetaan testiä..." - success: "Onnistui!" - failure: "Yritys ottaa yhteyttä malliin palautti tämän virheen: %{error}" - hints: - name: "Sisällytämme tämän API-kutsuun määrittääksemme, mitä mallia käytämme" - vision_enabled: "Jos tämä on käytössä, tekoäly yrittää ymmärtää kuvia. Tämä riippuu siitä, tukeeko käytettävä malli näköä. Anthropicin, Googlen ja OpenAI:n uusimmat mallit tukevat näköä." - enabled_chat_bot: "Jos tämä on käytössä, käyttäjät voivat valita tämän mallin luodessaan yksityisviestejä tekoälybotin kanssa" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Mukautettu" - provider_fields: - access_key_id: "AWS Bedrockin käyttöavaintunnus" - region: "AWS Bedrockin alue" - organization: "Valinnainen OpenAI:n organisaatiotunnus" - disable_system_prompt: "Poista järjestelmäviesti käytöstä kehotteissa" - enable_native_tool: "Ota käyttöön natiivityökalujen tuki" - disable_native_tools: "Poista natiivityökalujen tuki käytöstä (käytä XML-pohjaisia työkaluja)" - provider_order: "Palveluntarjoajien järjestys (pilkuilla eroteltu luettelo)" - provider_quantizations: "Palveluntarjoajien kvantisointien järjestys (pilkuilla eroteltu luettelo esim.: fp16,fp8)" - disable_streaming: "Poista suoratoistovalmistumiset käytöstä (muunna suoratoistopyynnöt ei-suoratoistopyynnöiksi)" - related_topics: - title: "Liittyvät ketjut" - pill: "Liittyy aiheeseen" - ai_helper: - title: "Ehdota muutoksia tekoälyn avulla" - description: "Valitse yksi seuraavista vaihtoehdoista, niin tekoäly ehdottaa sinulle uutta versiota tekstistä." - selection_hint: "Vihje: voit myös valita osan tekstistä ennen apuohjelman avaamista kirjoittaaksesi vain sen uudelleen." - suggest: "Ehdota tekoälyllä" - suggest_errors: - too_many_tags: - one: "Sinulla voi olla enintään %{count} tunniste" - other: "Sinulla voi olla enintään %{count} tunnistetta" - no_suggestions: "Ei ehdotuksia saatavilla" - missing_content: "Anna sisältöä ehdotusten luomiseksi." - context_menu: - trigger: "Kysy tekoälyltä" - loading: "Tekoäly tuottaa" - cancel: "Peruuta" - confirm: "Vahvista" - discard: "Hylkää" - changes: "Ehdotetut muokkaukset" - custom_prompt: - title: "Mukautettu kehote" - placeholder: "Anna mukautettu kehote..." - submit: "Lähetä kehote" - translate_prompt: "Käännä kielelle %{language}" - post_options_menu: - trigger: "Kysy tekoälyltä" - title: "Kysy tekoälyltä" - loading: "Tekoäly tuottaa" - close: "Sulje" - copy: "Kopioi" - copied: "Kopioitiin!" - cancel: "Peruuta" - insert_footnote: "Lisää alaviite" - footnote_disabled: "Automaattinen lisäys poistettu käytöstä, napsauta kopiointipainiketta ja lisää se manuaalisesti" - footnote_credits: "Tekoälyn selitys" - fast_edit: - suggest_button: "Ehdota muokkausta" - thumbnail_suggestions: - title: "Ehdotetut pikkukuvat" - select: "Valitse" - selected: "Valittu" - image_caption: - button_label: "Kuvateksti tekoälyllä" - generating: "Luodaan kuvatekstiä..." - credits: "Tekoälyn laatima kuvateksti" - save_caption: "Tallenna" - automatic_caption_setting: "Ota automaattinen kuvateksti käyttöön" - automatic_caption_loading: "Luodaan kuvatekstejä kuville..." - automatic_caption_dialog: - prompt: "Tämä viesti sisältää kuvia, joilla ei ole kuvatekstiä. Haluatko ottaa automaattiset kuvatekstit käyttöön ladattaessa kuvia verkkoon? (Voit muuttaa tätä myöhemmin asetuksissasi)" - confirm: "Ota käyttöön" - cancel: "Älä kysy uudelleen" - no_content_error: "Lisää ensin sisältöä tehdäksesi tekoälytoimia sille" - reviewables: - model_used: "Käytetty malli:" - accuracy: "Tarkkuus:" - embeddings: - short_title: "Upotukset" - new: "Uusi upotus" - back: "Takaisin" - save: "Tallenna" - saved: "Upotusmääritykset tallennettu" - delete: "Poista" - confirm_delete: Haluatko varmasti poistaa tämän upotusmäärityksen? - empty: "Et ole vielä määrittänyt upotuksia" - presets: "Valitse esiasetus..." - configure_manually: "Määritä manuaalisesti" - edit: "Muokkaa" - seeded_warning: "Tämä on valmiiksi määritetty sivustollesi, eikä sitä voi muokata." - tests: - title: "Suorita testi" - running: "Suoritetaan testiä..." - success: "Onnistui!" - failure: "Yritys luoda upotus johti seuraavaan tulokseen: %{error}" - hints: - dimensions_warning: "Tallentamisen jälkeen tätä arvoa ei voi enää muuttaa." - matryoshka_dimensions: "Määrittää sellaisten sisäkkäisten upotusten koon, joita käytetään tietojen hierarkkiseen tai monikerroksiseen esittämiseen samalla tavalla kuin maatuskanuket sopivat toisiinsa." - sequence_length: "Enimmäismäärä saneita, joka voidaan käsitellä kerralla luotaessa upotuksia tai käsiteltäessä kyselyä." - distance_function: "Määrittää, kuinka upotusten välinen samankaltaisuus lasketaan, käyttämällä joko kosinietäisyyttä (mittaamalla vektorien välisen kulman) tai negatiivista sisätuloa (mittaamalla vektorin arvojen päällekkäisyyttä)." - display_name: "Nimi" - provider: "Palveluntarjoaja" - url: "Upotuspalvelun URL" - api_key: "Upotuspalvelun API-avain" - tokenizer: "Tokenisoija" - dimensions: "Upotusmitat" - max_sequence_length: "Jakson pituus" - embed_prompt: "Upotuskehote" - search_prompt: "Hakukehote" - matryoshka_dimensions: "Maatuskamitat" - distance_function: "Etäisyysfunktio" - distance_functions: - "<#>": "Negatiivinen sisätulo" - <=>: "Kosinietäisyys" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Mukautettu" - provider_fields: - model_name: "Mallin nimi" - semantic_search: "Ketjut (semanttinen)" - semantic_search_loading: "Haetaan lisää tuloksia tekoälyllä" - semantic_search_results: - toggle: "Näytetään %{count} tulosta, jotka löydettiin tekoälyllä" - toggle_hidden: "Piilotetaan %{count} tulosta, jotka löydettiin tekoälyllä" - none: "Tekoälyhakumme ei valitettavasti löytänyt vastaavia aiheita" - new: "Aloita uusien tulosten etsiminen tekoälyllä painamalla hakupainiketta" - unavailable: "Tekoälytuloksia ei ole saatavilla" - semantic_search_tooltips: - results_explanation: "Kun tämä on käytössä, alle lisätään ylimääräisiä tekoälyhakutuloksia." - invalid_sort: "Hakutulokset täytyy olla järjestetty osuvuuden mukaan, jotta tekoälytuloksia voidaan näyttää" - semantic_search_unavailable_tooltip: "Hakutulokset täytyy olla järjestetty osuvuuden mukaan, jotta tekoälytuloksia voidaan näyttää" - ai_generated_result: "Tekoälyllä löydetty hakutulos" - quick_search: - suffix: "kaikissa ketjuissa ja viesteissä tekoälyllä" - ai_artifact: - expand_view_label: "Laajenna näkymä" - collapse_view_label: "Poistu koko näytön tilasta (Esc- tai Takaisin-painike)" - click_to_run_label: "Suorita artefakti" - ai_bot: - llm: "Malli" - pm_warning: "Valvojat tarkkailevat säännöllisesti tekoälychatbotin viestejä" - cancel_streaming: "Lopeta vastaus" - default_pm_prefix: "[Nimetön tekoälybotin yksityisviesti]" - shortcut_title: "Aloita yksityiskeskustelu tekoälybotin kanssa" - share: "Kopioi tekoälykeskustelu" - conversation_shared: "Keskustelu kopioitu" - debug_ai: "Katso raaka tekoälypyyntö ja vastaus" - debug_ai_modal: - title: "Näytä tekoälyvuorovaikutus" - copy_request: "Kopioi pyyntö" - copy_response: "Kopioi vastaus" - request_tokens: "Pyynnön tokenit:" - response_tokens: "Vastauksen tokenit:" - request: "Pyyntö" - response: "Vastaus" - next_log: "Seuraava" - previous_log: "Edellinen" - share_full_topic_modal: - title: "Jaa keskustelu julkisesti" - share: "Jaa ja kopioi linkki" - update: "Päivitä ja kopioi linkki" - delete: "Poista jako" - share_ai_conversation: - name: "Jaa tekoälykeskustelu" - title: "Jaa tämä tekoälykeskustelu julkisesti" - invite_ai_conversation: - button: "Kutsu" - ai_label: "Tekoäly" - ai_title: "Keskustelu tekoälyn kanssa" - share_modal: - title: "Kopioi tekoälykeskustelu" - copy: "Kopioi" - context: "Jaettavat vuorovaikutukset:" - share_tip: "Vaihtoehtoisesti voit jakaa koko keskustelun" - bot_names: - fake: "Valetestibotti" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Tänään" - last_7_days: "Viimeiset 7 päivää" - last_30_days: "Viimeiset 30 päivää" - sentiments: - dashboard: - title: "Tunne" - sentiment_analysis: - filter_types: - all: "Kaikki" - positive: "Positiivinen" - neutral: "Neutraali" - negative: "Negatiivinen" - group_types: - category: "Luokka" - tag: "Tunniste" - table: - sentiment: "Tunne" - total_count: "Yhteensä" - summarization: - chat: - title: "Tee yhteenveto viesteistä" - description: "Valitse vaihtoehto alla yhteenvedon tekemiseksi haluttuna ajanjaksona lähetetystä keskustelusta." - summarize: "Tee yhteenveto" - since: - one: "Viimeinen tunti" - other: "Viimeiset %{count} tuntia" - topic: - title: "Ketjun yhteenveto" - close: "Sulje yhteenvetopaneeli" - topic_list_layout: - button: - compact: "Kompakti" - expanded: "Laajennettu" - expanded_description: "tekoäly-yhteenvedoilla" - discobot_discoveries: - regular_results: "Aiheet" - collapse: "Kutista" - tooltip: - actions: - disable: "Poista käytöstä" - review: - types: - reviewable_ai_post: - title: "Tekoälyn liputtama viesti" - reviewable_ai_chat_message: - title: "Tekoälyn liputtama chat-viesti" diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml deleted file mode 100644 index b611a060..00000000 --- a/config/locales/client.fr.yml +++ /dev/null @@ -1,686 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -fr: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Permet la recherche alimentée par l'IA" - stream_completion: "Permet de diffuser en continu les complétions de personnages IA" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "Émotion" - description: "Le tableau répertorie le nombre de publications classées selon une émotion déterminée. Cette classification est réalisée avec le modèle « Samlowe/Roberta-Base-Go_Emotions »." - reports: - filters: - sort_by: - label: "Trier par" - tag: - label: "Étiqueter" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Expéditeur" - description: "L'utilisateur qui enverra le rapport" - receivers: - label: "Récepteurs" - description: "Les utilisateurs qui recevront le rapport (les e-mails seront envoyés directement par e-mail, les noms d'utilisateur seront envoyés par MP)" - topic_id: - label: "ID du sujet" - description: "L'ID du sujet dans lequel publier le rapport" - title: - label: "Titre" - description: "Le titre du rapport" - days: - label: "Jours" - description: "La durée du rapport" - offset: - label: "Décalage" - description: "Lors des tests, vous souhaiterez peut-être exécuter le rapport de manière historique, utilisez le décalage pour démarrer le rapport à une date antérieure." - instructions: - label: "Instructions" - description: "Les instructions fournies au modèle de langage" - sample_size: - label: "Taille de l'échantillon" - description: "Le nombre de publications à échantillonner pour le rapport" - tokens_per_post: - label: "Jetons par publication" - description: "Le nombre de jetons LLM à utiliser par publication" - model: - label: "Modèle" - description: "LLM à utiliser pour la génération de rapports" - categories: - label: "Catégories" - description: "Filtrer les sujets uniquement selon ces catégories" - tags: - label: "Étiquettes" - description: "Filtrer les sujets uniquement selon ces étiquettes" - exclude_tags: - label: "Exclure les étiquettes" - description: "Exclure les sujets comportant ces étiquettes" - exclude_categories: - label: "Exclure les catégories" - description: "Exclure les sujets comportant ces catégories" - allow_secure_categories: - label: "Autoriser les catégories sécurisées" - description: "Autoriser la génération du rapport pour les sujets classés dans des catégories sécurisées" - suppress_notifications: - label: "Supprimer les notifications" - description: "Supprimez les notifications que le rapport peut générer en les transformant en contenu. Cela permettra de réorganiser les mentions et les liens internes." - debug_mode: - label: "Mode de débogage" - description: "Activez le mode de débogage pour voir les entrées et sorties brutes du LLM" - priority_group: - label: "Groupe prioritaire" - description: "Priorisez le contenu de ce groupe dans le rapport" - temperature: - label: "Température" - top_p: - label: "Top P" - llm_tool_triage: - fields: - model: - label: "Modèle" - llm_triage: - fields: - system_prompt: - label: "Invite du système" - description: "L'invite qui sera utilisée pour le tri, assurez-vous qu'elle répond avec un seul mot que vous pouvez utiliser pour déclencher l'action" - max_post_tokens: - label: "Nombre maximum de jetons de publication" - description: "Le nombre maximal de jetons à analyser à l'aide du triage LLM" - stop_sequences: - label: "Arrêter les séquences" - description: "Demandez au modèle d'arrêter la génération de jetons lorsqu'il atteint l'une de ces valeurs" - search_for_text: - label: "Recherche de texte" - description: "Si le texte suivant apparaît dans la réponse LLM, appliquez ces actions" - category: - label: "Catégorie" - description: "Catégorie à appliquer au sujet" - tags: - label: "Étiquettes" - description: "Étiquettes à appliquer au sujet" - canned_reply: - label: "Réponse" - description: "Texte brut de la réponse prédéfinie à la publication dans le sujet" - canned_reply_user: - label: "Répondre à l'utilisateur" - description: "Nom d'utilisateur de l'utilisateur qui publiera la réponse prédéfinie" - hide_topic: - label: "Masquer le sujet" - description: "Rendez le sujet invisible au public si l'action est déclenchée" - flag_type: - label: "Type de signalement" - description: "Type de signalement à appliquer au message (spam ou simplement marquer pour examen)" - flag_post: - label: "Signaler cette publication" - description: "Signale la publication (soit comme spam ou pour examen)" - include_personal_messages: - label: "Inclure les messages personnels" - description: "Analysez et triez également les messages personnels" - model: - label: "Modèle" - description: "Modèle linguistique utilisé pour le tri" - temperature: - label: "Température" - discourse_ai: - title: "IA" - features: - back: "Retour" - disabled: "(désactivée)" - groups: "Groupes:" - no_persona: "Non défini" - no_groups: "Jamais" - edit: "Modifier" - expand_list: - one: "(%{count} autres)" - other: "(%{count} autres)" - collapse_list: "(afficher moins)" - filters: - all: "Tout" - reset: "Réinitialiser" - search: - name: "Recherche" - embeddings: - name: "Intégrations" - ai_helper: - name: "Assistant" - proofread: Relisez le texte - explain: "Expliquer" - smart_dates: "Dates intelligentes" - markdown_tables: "Générer un tableau Markdown" - custom_prompt: "Invite personnalisée" - spam: - name: "Spam" - description: "Identifie les spams potentiels à l'aide du LLM sélectionné et les signale aux modérateurs du site afin qu'ils les inspectent dans la file d'attente de révision" - modals: - select_option: "Sélectionnez une option..." - spam: - short_title: "Spam" - title: "Configurer la gestion du spam" - select_llm: "Sélectionner LLM" - custom_instructions: "Instructions personnalisées" - custom_instructions_help: "Des instructions personnalisées spécifiques à votre site pour aider l'IA à identifier le spam, par exemple « Faites preuve d'agressivité dans l'analyse des messages qui ne sont pas en anglais »." - last_seven_days: "Ces 7 derniers jours" - scanned_count: "Publications analysées" - false_positives: "Signalé incorrectement" - false_negatives: "Spam manqué" - spam_detected: "Spam détecté" - custom_instructions_placeholder: "Instructions spécifiques au site pour que l'IA puisse identifier les spams avec plus de précision" - enable: "Activer" - spam_tip: "La détection de spams par l'IA analysera les 3 premiers messages de tous les nouveaux utilisateurs sur les sujets publics. Elle les signalera pour examen et bloquera les utilisateurs s'ils sont susceptibles d'envoyer du spam." - settings_saved: "Paramètres enregistrés" - spam_description: "Identifie les spams potentiels à l'aide du LLM sélectionné et les signale aux modérateurs du site afin qu'ils les inspectent dans la file d'attente de révision" - no_llms: "Aucun LLM disponible" - test_button: "Test..." - save_button: "Enregistrer les modifications" - test_modal: - title: "Tester la détection du spam" - post_url_label: "URL ou ID de la publication" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 ou ID de la publication" - result: "Résultat" - scan_log: "Journal d'analyse" - run: "Lancer le test" - spam: "Spam" - not_spam: "Non spam" - stat_tooltips: - incorrectly_flagged: "Éléments que le robot IA a signalés comme spam et pour lesquels les modérateurs n'étaient pas d'accord" - missed_spam: "Éléments signalés par la communauté comme spam qui n'ont pas été détectés par le robot IA, et sur lesquels les modérateurs ont été d'accord" - errors: - scan_not_admin: - message: "Avertissement : l'analyse anti-spam ne fonctionnera pas correctement, car le compte d'analyse anti-spam n'est pas un compte administrateur" - action: "Corriger" - resolved: "L'erreur a été résolue !" - usage: - short_title: "Utilisation" - summary: "Résumé" - total_tokens: "Total des jetons" - tokens_over_time: "Jetons au fil du temps" - features_breakdown: "Utilisation par fonctionnalité" - feature: "Fonctionnalité" - usage_count: "Nombre d'utilisations" - model: "Modèle" - models_breakdown: "Utilisations par modèle" - users_breakdown: "Utilisations par utilisateur" - all_features: "Toutes les fonctionnalités" - all_models: "Tous les modèles" - username: "Nom d'utilisateur" - total_requests: "Total des requêtes" - request_tokens: "Jetons de requête" - response_tokens: "Jetons de réponse" - net_request_tokens: "Jetons de requête nets" - cached_tokens: "Jetons mis en cache" - cached_request_tokens: "Jetons de requête en cache" - no_users: "Aucune donnée d'utilisation de l'utilisateur n'a été trouvée" - no_models: "Aucune donnée d'utilisation du modèle n'a été trouvée" - no_features: "Aucune donnée d'utilisation de la fonctionnalité n'a été trouvée" - subheader_description: "Les jetons sont les unités de base que les LLM utilisent pour comprendre et générer du texte. Les données d'utilisation peuvent affecter les coûts" - stat_tooltips: - total_requests: "Toutes les requêtes adressées aux LLM via Discourse" - total_tokens: "Tous les jetons utilisés lors de la consultation d'un LLM" - request_tokens: "Jetons utilisés lorsque le LLM essaie de comprendre ce que vous dites" - response_tokens: "Jetons utilisés lorsque le LLM répond à votre invite" - cached_tokens: "Jetons de requête précédemment traités que le LLM réutilise pour optimiser les performances et les coûts" - periods: - last_day: "Ces dernières 24 heures" - last_week: "La semaine dernière" - last_month: "Le mois dernier" - custom: "Personnalisé…" - ai_persona: - ai_tools: "Outils" - tool_strategies: - all: "Appliquer à toutes les réponses" - replies: - one: "Appliquer à la première réponse uniquement" - other: "Appliquer aux %{count} premières réponses" - back: "Retour" - name: "Nom" - edit: "Modifier" - export: "Exporter" - description: "Description" - no_llm_selected: "Aucun modèle linguistique sélectionné" - max_context_posts: "Nombre maximal de publications contextuelles" - max_context_posts_help: "Le nombre maximal de publications à utiliser comme contexte pour l'IA lorsqu'elle répond à un utilisateur. (vide par défaut)" - vision_enabled: Vision activée - vision_enabled_help: Si cette option est activée, l'IA tentera de comprendre les images publiées par les utilisateurs dans le sujet, en fonction du modèle utilisé prenant en charge la vision. Pris en charge par les derniers modèles d'Anthropic, Google et OpenAI. - vision_max_pixels: Taille d'image prise en charge - vision_max_pixel_sizes: - low: Qualité faible - moins cher (256x256) - medium: Qualité moyenne (512x512) - high: Qualité élevée - plus lent (1024x1024) - tool_details: Afficher les détails de l'outil - tool_details_help: Affiche aux utilisateurs finaux des informations sur les outils que le modèle linguistique a déclenchés. - mentionable: Autoriser les mentions - mentionable_help: Si cette option est activée, les utilisateurs des groupes autorisés peuvent mentionner cet utilisateur dans les publications, l'IA répondra en tant que personnage. - user: Utilisateur - create_user: Créer un utilisateur - create_user_help: Vous pouvez éventuellement associer un utilisateur à ce personnage. Si vous le faites, l'IA utilisera cet utilisateur pour répondre aux demandes. - default_llm: Modèle linguistique par défaut - default_llm_help: Le modèle linguistique par défaut à utiliser pour ce personnage. Obligatoire si vous souhaitez mentionner un personnage sur des publications publiques. - question_consolidator_llm: Modèle linguistique pour le consolidateur de questions - question_consolidator_llm_help: Le modèle linguistique à utiliser pour le consolidateur de questions, vous pouvez choisir un modèle moins puissant pour réduire les coûts. - system_prompt: Invite du système - forced_tool_strategy: Stratégie d'outil forcé - allow_chat_direct_messages: "Autoriser les messages directs de discussion" - allow_chat_direct_messages_help: "Si cette option est activée, les utilisateurs des groupes autorisés peuvent envoyer des messages directs à ce personnage." - allow_chat_channel_mentions: "Autoriser les mentions du canal de discussion" - allow_chat_channel_mentions_help: "Si cette option est activée, les utilisateurs des groupes autorisés peuvent mentionner ce personnage dans les canaux de discussion." - allow_personal_messages: "Autoriser les messages personnels" - allow_personal_messages_help: "Si cette option est activée, les utilisateurs des groupes autorisés peuvent envoyer des messages personnels à ce personnage." - allow_topic_mentions: "Autoriser les mentions de sujets" - allow_topic_mentions_help: "Si cette option est activée, les utilisateurs des groupes autorisés peuvent mentionner ce personnage dans les sujets." - force_default_llm: "Toujours utiliser le modèle linguistique par défaut" - save: "Enregistrer" - saved: "Personnage enregistré" - enabled: "Activé ?" - tools: "Outils activés" - forced_tools: "Outils forcés" - allowed_groups: "Groupes autorisés" - confirm_delete: "Voulez-vous vraiment supprimer ce personnage ?" - new: "Nouveau personnage" - no_personas: "Vous n'avez pas encore créé de personnage" - title: "Personnages" - short_title: "Personnages" - delete: "Supprimer" - temperature: "Température" - temperature_help: "Température à utiliser pour le LLM. Augmentez la valeur pour augmenter la créativité (laissez le champ vide pour utiliser la valeur par défaut du modèle, généralement une valeur comprise entre 0,0 et 2,0)" - top_p: "Top P" - top_p_help: "Top P à utiliser pour le LLM, augmentez pour augmenter le caractère aléatoire (laissez vide pour utiliser la valeur par défaut du modèle, généralement une valeur comprise entre 0,0 et 1,0)" - priority: "Priorité" - priority_help: "Les personnages prioritaires sont affichés aux utilisateurs en haut de la liste des personnages. Si plusieurs personnages sont prioritaires, ils seront triés par ordre alphabétique." - tool_options: "Options de l'outil" - rag_conversation_chunks: "Rechercher des morceaux de conversation" - rag_conversation_chunks_help: "Le nombre de segments à utiliser pour les recherches de modèles RAG. Augmentez pour augmenter la quantité de contexte que l'IA peut utiliser." - persona_description: "Les personnages sont une fonctionnalité puissante qui vous permet de personnaliser le comportement du moteur d'IA dans votre forum Discourse. Ils agissent comme un « message système » qui guide les réponses et les interactions de l'IA, en contribuant ainsi à créer une expérience utilisateur plus personnalisée et plus interactive." - response_format: - open_modal: "Modifier" - modal: - key_title: "Clé" - filters: - reset: "Réinitialiser" - rag: - options: - rag_chunk_tokens: "Téléverser des jetons de morceaux" - rag_chunk_tokens_help: "Le nombre de jetons à utiliser pour chaque morceau du modèle RAG. Augmentez la valeur pour augmenter la quantité de contexte que l'IA peut utiliser. (La modification indexera à nouveau tous les téléversements)" - rag_chunk_overlap_tokens: "Téléverser des jetons de chevauchement de morceaux" - rag_chunk_overlap_tokens_help: "Le nombre de jetons à superposer entre les morceaux dans le modèle RAG. (La modification indexera à nouveau tous les téléversements)" - show_indexing_options: "Afficher les options de téléversement" - hide_indexing_options: "Masquer les options de téléversement" - uploads: - title: "Fichiers envoyés" - button: "Ajouter des fichiers" - filter: "Filtrer les téléversements" - indexed: "Indexé" - indexing: "Indexation" - uploaded: "Prêt à être indexé" - uploading: "Envoi en cours…" - remove: "Supprimer le téléversement" - tools: - back: "Retour" - short_title: "Outils" - export: "Exporter" - no_tools: "Vous n'avez pas encore créé d'outil" - name: "Nom" - new: "Nouvel outil" - description: "Description" - description_help: "Une description claire de l'objectif de l'outil pour le modèle linguistique" - subheader_description: "Les outils étendent les capacités des robots IA avec des fonctions JavaScript définies par l'utilisateur." - summary: "Résumé" - summary_help: "Résumé des outils destinés à être affichés aux utilisateurs finaux" - script: "Script" - parameters: "Paramètres" - save: "Enregistrer" - remove_parameter: "Supprimer" - parameter_required: "Requis" - parameter_enum: "Énumération" - parameter_name: "Nom du paramètre" - parameter_description: "Description du paramètre" - enum_value: "Valeur d'énumération" - add_enum_value: "Ajouter une valeur d'énumération" - edit: "Modifier" - test: "Lancer le test" - delete: "Supprimer" - saved: "Outil enregistré" - confirm_delete: "Voulez-vous vraiment supprimer cet outil ?" - test_modal: - title: "Tester l'outil d'IA" - run: "Lancer le test" - result: "Résultat du test" - llms: - short_title: "LLM" - no_llms: "Pas encore de LLM" - new: "Nouveau modèle" - display_name: "Nom" - name: "ID du modèle" - provider: "Fournisseur" - tokenizer: "Tokéniseur" - url: "URL du service hébergeant le modèle" - api_key: "Clé API du service hébergeant le modèle" - enabled_chat_bot: "Autoriser le sélecteur de robot IA" - vision_enabled: "Vision activée" - ai_bot_user: "Utilisateur robot IA" - save: "Enregistrer" - edit: "Modifier" - saved: "Modèle LLM enregistré" - back: "Retour" - confirm_delete: Voulez-vous vraiment supprimer ce modèle ? - delete: Supprimer - seeded_warning: "Ce modèle est préconfiguré sur votre site et ne peut pas être modifié." - quotas: - title: "Quotas d'utilisation" - add_title: "Créer un nouveau quota" - group: "Groupe" - max_tokens: "Nombre maximum de jetons" - max_usages: "Nombre max. d'utilisations" - duration: "Durée " - confirm_delete: "Voulez-vous vraiment supprimer ce quota ?" - add: "Ajouter un quota" - durations: - hour: "1 heure" - six_hours: "6 heures" - day: "24 heures" - week: "7 jours" - custom: "Personnalisé…" - hours: "heures" - max_tokens_help: "Nombre maximal de jetons (mots et caractères) que chaque utilisateur de ce groupe peut utiliser dans la durée spécifiée. Les jetons sont les unités utilisées par les modèles d'IA pour traiter le texte : environ 1 jeton = 4 caractères ou 3/4 d'un mot." - max_usages_help: "Nombre maximal de fois que chaque utilisateur de ce groupe peut utiliser le modèle d'IA dans la durée spécifiée. Ce quota est suivi par utilisateur individuel et n'est pas partagé au sein du groupe." - usage: - ai_bot: "Robot IA" - ai_helper: "Assistant" - ai_persona: "Personnage (%{persona})" - ai_summarization: "Résumer" - ai_embeddings_semantic_search: "Recherche IA" - ai_spam: "Spam" - in_use_warning: - one: "Ce modèle est actuellement utilisé par %{settings}. Si la configuration est incorrecte, la fonctionnalité ne fonctionnera pas comme prévu." - other: "Ce modèle est actuellement utilisé par %{settings}. Si la configuration est incorrecte, les fonctionnalités ne fonctionneront pas comme prévu. " - model_description: - none: "Paramètres généraux qui fonctionnent pour la plupart des modèles linguistiques" - anthropic-claude-opus-4-0: "Le modèle le plus intelligent d'Anthropic" - anthropic-claude-3-5-haiku-latest: "Rapide et économique" - google-gemini-2-5-flash: "Léger, rapide et économique avec raisonnement multimodal" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Modèle multilingue léger et efficace" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Modèle polyvalent et puissant" - mistral-mistral-large-latest: "Le modèle le plus puissant de Mistral" - mistral-pixtral-large-latest: "Le modèle de Mistral le plus performant en matière de vision" - preseeded_model_description: "Modèle open source préconfiguré utilisant %{model}" - configured: - title: "LLM configurés" - preconfigured_llms: "Sélectionnez votre LLM" - preconfigured: - title_no_llms: "Sélectionnez un modèle pour commencer" - title: "Modèles LLM non configurés" - description: "Les LLM (Large Language Models) sont des outils d'IA optimisés pour des tâches telles que la synthèse de contenu, la génération de rapports, l'automatisation des interactions avec les clients et la facilitation de la modération et des informations sur les forums" - fake: "Configuration manuelle" - button: "Configurer" - next: - title: "Suivant" - tests: - title: "Lancer le test" - running: "Exécution du test..." - success: "Succès !" - failure: "La tentative de contact avec le modèle a renvoyé cette erreur : %{error}" - hints: - name: "Nous l'incluons dans l'appel d'API pour spécifier le modèle que nous allons utiliser" - vision_enabled: "Si cette option est activée, l'IA tentera de comprendre les images. Cela dépend du modèle utilisé prenant en charge la vision. Pris en charge par les derniers modèles d'Anthropic, Google et OpenAI." - enabled_chat_bot: "Si cette option est activée, les utilisateurs peuvent sélectionner ce modèle lors de la création de MD avec le robot IA" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Personnalisé" - provider_fields: - access_key_id: "ID de clé d'accès AWS Bedrock" - region: "Région AWS Bedrock" - organization: "ID d'organisation OpenAI facultatif" - disable_system_prompt: "Désactiver le message système dans les invites" - enable_native_tool: "Activer la prise en charge des outils natifs" - disable_native_tools: "Désactiver la prise en charge des outils natifs (utiliser des outils basés sur XML)" - provider_order: "Ordre des fournisseurs (liste délimitée par des virgules)" - provider_quantizations: "Ordre de quantification des fournisseurs (liste délimitée par des virgules, par exemple : fp16,fp8)" - disable_streaming: "Désactiver les complétions de streaming (convertir les requêtes de streaming en requêtes non-streaming)" - related_topics: - title: "Sujets connexes" - pill: "Lié" - ai_helper: - title: "Suggérer des modifications à l'aide de l'IA" - description: "Choisissez l'une des options ci-dessous et l'IA vous proposera une nouvelle version du texte." - selection_hint: "Conseil : vous pouvez également sélectionner une partie du texte avant d'ouvrir l'assistant pour ne réécrire que cette partie." - suggest: "Suggérer avec l'IA" - suggest_errors: - too_many_tags: - one: "Vous ne pouvez avoir qu'un maximum de %{count} étiquette" - other: "Vous ne pouvez avoir qu'un maximum de %{count} étiquettes" - no_suggestions: "Aucune suggestion disponible" - missing_content: "Veuillez saisir du contenu pour générer des suggestions." - context_menu: - trigger: "Demander à l'IA" - loading: "L'IA est en train de générer" - cancel: "Annuler" - confirm: "Confirmer" - discard: "Abandonner" - changes: "Modifications suggérées" - custom_prompt: - title: "Invite personnalisée" - placeholder: "Saisissez une invite personnalisée..." - submit: "Envoyer une invite" - translate_prompt: "Traduire en %{language}" - post_options_menu: - trigger: "Demander à l'IA" - title: "Demander à l'IA" - loading: "L'IA est en train de générer" - close: "Fermer" - copy: "Copier" - copied: "Copié !" - cancel: "Annuler" - insert_footnote: "Ajouter une note de bas de page" - footnote_disabled: "Insertion automatique désactivée. Cliquez sur le bouton Copier et modifiez-le manuellement" - footnote_credits: "Explication par l'IA" - fast_edit: - suggest_button: "Suggérer une modification" - thumbnail_suggestions: - title: "Miniatures suggérées" - select: "Sélectionner" - selected: "Sélectionné" - image_caption: - button_label: "Légende avec IA" - generating: "Génération de la légende..." - credits: "Légende par l'IA" - save_caption: "Enregistrer" - automatic_caption_setting: "Activer le sous-titrage automatique" - automatic_caption_loading: "Ajout des légendes des images..." - automatic_caption_dialog: - prompt: "Cette publication contient des images non légendées. Souhaitez-vous activer les légendes automatiques lors du téléversement d'images ? (Cela peut être modifié ultérieurement dans vos préférences)" - confirm: "Activer" - cancel: "Ne plus demander" - no_content_error: "Ajoutez d'abord du contenu pour y effectuer des actions d'IA" - reviewables: - model_used: "Modèle utilisé :" - accuracy: "Précision :" - embeddings: - short_title: "Intégrations" - new: "Nouvelle intégration" - back: "Retour" - save: "Enregistrer" - saved: "Configuration d'intégration enregistrée" - delete: "Supprimer" - confirm_delete: Voulez-vous vraiment supprimer cette configuration d'intégration ? - empty: "Vous n'avez pas encore configuré les intégrations" - presets: "Sélectionnez un préréglage..." - configure_manually: "Configurer manuellement" - edit: "Modifier" - seeded_warning: "Cela est préconfiguré sur votre site et ne peut pas être modifié." - tests: - title: "Lancer le test" - running: "Exécution du test..." - success: "Succès !" - failure: "La tentative de génération d'une intégration a donné le résultat suivant : %{error}" - hints: - dimensions_warning: "Une fois enregistrée, cette valeur ne peut plus être modifiée." - matryoshka_dimensions: "Définit la taille des intégrations imbriquées utilisées pour la représentation hiérarchique ou multicouche des données, de la même manière que les poupées imbriquées s'emboîtent les unes dans les autres." - sequence_length: "Le nombre maximal de jetons pouvant être traités simultanément lors de la création d'intégrations ou du traitement d'une requête." - distance_function: "Détermine comment la similarité entre les intégrations est calculée, en utilisant soit la distance cosinus (mesure de l'angle entre les vecteurs) soit un produit interne négatif (mesure du chevauchement des valeurs vectorielles)." - display_name: "Nom" - provider: "Fournisseur" - url: "URL du service d'intégration" - api_key: "Clé d'API du service d'intégration" - tokenizer: "Tokéniseur" - dimensions: "Dimensions d'intégration" - max_sequence_length: "Longueur de la séquence" - embed_prompt: "Invite d'intégration" - search_prompt: "Invite de recherche" - matryoshka_dimensions: "Dimensions de la matriochka" - distance_function: "Fonction de distance" - distance_functions: - "<#>": "Produit interne négatif" - <=>: "Distance cosinus" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Personnalisé" - provider_fields: - model_name: "Nom du modèle" - semantic_search: "Sujets (sémantiques)" - semantic_search_loading: "Rechercher plus de résultats à l'aide de l'IA" - semantic_search_results: - toggle: "Affichage de %{count} résultats trouvés en utilisant l'IA" - toggle_hidden: "Masquer %{count} résultats trouvés à l’aide de l'IA" - none: "Nous sommes désolés, notre recherche par IA n'a trouvé aucun sujet correspondant" - new: "Appuyez sur « Rechercher » pour commencer à rechercher de nouveaux résultats avec l'IA" - unavailable: "Résultats de l'IA indisponibles" - semantic_search_tooltips: - results_explanation: "Lorsque cette option est activée, des résultats de recherche IA supplémentaires seront ajoutés ci-dessous." - invalid_sort: "Les résultats de la recherche doivent être triés par pertinence pour afficher les résultats de l'IA" - semantic_search_unavailable_tooltip: "Les résultats de la recherche doivent être triés par pertinence pour afficher les résultats de l'IA" - ai_generated_result: "Résultat de recherche trouvé à l'aide de l'IA" - quick_search: - suffix: "dans tous les sujets et publications avec IA" - ai_artifact: - expand_view_label: "Agrandir la vue" - collapse_view_label: "Quitter le mode plein écran (bouton ESC ou Retour)" - click_to_run_label: "Exécuter l'artefact" - ai_bot: - llm: "Modèle" - pm_warning: "Les messages du chatbot IA sont surveillés régulièrement par les modérateurs." - cancel_streaming: "Arrêter de répondre" - default_pm_prefix: "[Message privé de robot IA sans titre]" - shortcut_title: "Démarrer une conversation avec un robot IA" - share: "Copier la conversation avec l'IA" - conversation_shared: "Conversation copiée" - debug_ai: "Afficher la demande et la réponse brutes de l'IA" - debug_ai_modal: - title: "Afficher l'interaction avec l'IA" - copy_request: "Copier la demande" - copy_response: "Copier la réponse" - request_tokens: "Jetons de demande :" - response_tokens: "Jetons de réponse :" - request: "Requête" - response: "Réponse" - next_log: "Suivant" - previous_log: "Précédent" - share_full_topic_modal: - title: "Partager la conversation publiquement" - share: "Partager et copier le lien" - update: "Mettre à jour et copier le lien" - delete: "Supprimer le partage" - share_ai_conversation: - name: "Partager une conversation IA" - title: "Partager publiquement cette conversation avec l'IA" - invite_ai_conversation: - button: "Inviter" - ai_label: "IA" - ai_title: "Conversation avec l'IA" - share_modal: - title: "Copier la conversation avec l'IA" - copy: "Copie" - context: "Interactions à partager :" - share_tip: "Vous pouvez également partager l'intégralité de la conversation" - bot_names: - fake: "Faux robot de test" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Aujourd'hui" - last_7_days: "Ces 7 derniers jours" - last_30_days: "Ces 30 derniers jours" - sentiments: - dashboard: - title: "Sentiment" - sentiment_analysis: - filter_types: - all: "Tout" - positive: "Positif" - neutral: "Neutre" - negative: "Négatif" - group_types: - category: "Catégorie" - tag: "Étiqueter" - table: - sentiment: "Sentiment" - total_count: "Total" - summarization: - chat: - title: "Résumer les messages" - description: "Sélectionnez une option ci-dessous pour résumer la conversation envoyée pendant la période souhaitée." - summarize: "Résumer" - since: - one: "Dernière heure" - other: "%{count} dernières heures" - topic: - title: "Résumé du sujet" - close: "Fermer le panneau du résumé" - topic_list_layout: - button: - compact: "Compact" - expanded: "Étendu" - expanded_description: "avec des résumés d'IA" - discobot_discoveries: - regular_results: "Sujets" - collapse: "Réduire" - tooltip: - actions: - disable: "Désactiver" - review: - types: - reviewable_ai_post: - title: "Publication signalée par l'IA" - reviewable_ai_chat_message: - title: "Message de conversation signalé par l'IA" diff --git a/config/locales/client.gl.yml b/config/locales/client.gl.yml deleted file mode 100644 index 7aec9665..00000000 --- a/config/locales/client.gl.yml +++ /dev/null @@ -1,176 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -gl: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Ordenar por" - tag: - label: "Etiqueta" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID do tema" - title: - label: "Título" - categories: - label: "Categorías" - tags: - label: "Etiquetas" - llm_triage: - fields: - category: - label: "Categoría" - tags: - label: "Etiquetas" - canned_reply: - label: "Responder" - discourse_ai: - features: - back: "Volver" - disabled: "(desactivado)" - groups: "Grupos:" - no_groups: "Ningunha" - edit: "Editar" - expand_list: - one: "(%{count} máis)" - other: "(%{count} máis)" - filters: - all: "Todas" - reset: "Restabelecer" - search: - name: "Buscar" - spam: - name: "Lixo" - modals: - select_option: "Seleccione unha opción..." - spam: - short_title: "Lixo" - enable: "Activar" - test_modal: - spam: "Lixo" - usage: - summary: "Resumo" - username: "Nome de usuario" - total_requests: "Solicitudes totais" - periods: - custom: "Personalizar..." - ai_persona: - back: "Volver" - name: "Nome" - edit: "Editar" - export: "Exportar" - description: "Descrición" - user: Usuario - save: "Gardar" - enabled: "Activado?" - allowed_groups: "Grupos permitidos" - delete: "Eliminar" - response_format: - open_modal: "Editar" - modal: - key_title: "Chave" - filters: - reset: "Restabelecer" - rag: - uploads: - title: "Cargas" - uploading: "Cargando..." - tools: - back: "Volver" - export: "Exportar" - name: "Nome" - description: "Descrición" - summary: "Resumo" - save: "Gardar" - remove_parameter: "Retirar" - parameter_required: "Obrigatorio" - edit: "Editar" - delete: "Eliminar" - llms: - display_name: "Nome" - save: "Gardar" - edit: "Editar" - back: "Volver" - delete: Eliminar - quotas: - group: "Grupo" - duration: "Duración" - durations: - hour: "1 hora" - six_hours: "6 horas" - day: "24 horas" - custom: "Personalizar..." - hours: "horas" - usage: - ai_spam: "Lixo" - next: - title: "Seguinte" - tests: - success: "Feito!" - providers: - google: "Google" - fake: "Personalizado" - ai_helper: - context_menu: - cancel: "Cancelar" - confirm: "Confirmar" - discard: "Desbotar" - post_options_menu: - close: "Pechar" - copy: "Copiar" - copied: "Copiado!" - cancel: "Cancelar" - image_caption: - save_caption: "Gardar" - automatic_caption_dialog: - confirm: "Activar" - embeddings: - back: "Volver" - save: "Gardar" - delete: "Eliminar" - edit: "Editar" - tests: - success: "Feito!" - display_name: "Nome" - providers: - google: "Google" - fake: "Personalizado" - ai_bot: - debug_ai_modal: - request: "Petición" - response: "Resposta" - next_log: "Seguinte" - previous_log: "Anterior" - invite_ai_conversation: - button: "Convidar" - share_modal: - copy: "Copiar" - conversations: - today: "Hoxe" - sentiments: - sentiment_analysis: - filter_types: - all: "Todas" - neutral: "Neutro" - group_types: - category: "Categoría" - tag: "Etiqueta" - table: - total_count: "Total" - discobot_discoveries: - regular_results: "Temas" - collapse: "Pregar" - tooltip: - actions: - disable: "Desactivar" diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml deleted file mode 100644 index fe95e593..00000000 --- a/config/locales/client.he.yml +++ /dev/null @@ -1,831 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -he: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "מאפשר חיפוש בינה מלאכותית" - stream_completion: "מאפשר הזרמת השלמות דמות בינה מלאכותית" - update_personas: "מאפשר לעדכן דמויות בינה מלאכותית" - site_settings: - categories: - discourse_ai: "בינה מלאכותית ב־Discourse" - dashboard: - emotion: - title: "רגש" - description: "הטבלה מפרטת את כמות הפוסטים שסווגו עם רגש שנקבע. הסיווג נעשה בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - reports: - filters: - group_by: - label: "קיבוץ לפי" - sort_by: - label: "מיון על פי" - tag: - label: "תגית" - logs: - staff_actions: - actions: - create_ai_llm_model: "יצירת מודל LLM" - update_ai_llm_model: "עדכון מודל LLM" - delete_ai_llm_model: "מחיקת מודל LLM" - create_ai_persona: "יצירת דמות בינה מלאכותית" - update_ai_persona: "עדכון דמות בינה מלאכותית" - delete_ai_persona: "מחיקת דמות בינה מלאכותית" - create_ai_tool: "יצירת כלי בינה מלאכותית" - update_ai_tool: "עדכון כלי בינה מלאכותית" - delete_ai_tool: "מחיקת כלי בינה מלאכותית" - create_ai_embedding: "יצירת הטמעת בינה מלאכותית" - update_ai_embedding: "עדכון הטמעת בינה מלאכותית" - delete_ai_embedding: "מחיקת הטמעת בינה מלאכותית" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "מוען" - description: "המשתמש שישלח את הדוח" - receivers: - label: "נמענים" - description: "המשתמשים שיקבלו את הדוח (כתובות דוא״ל - יועבר לדוא״ל ישירות, שמות משתמשים - הודעות פרטיות במערכת)" - topic_id: - label: "מזהה נושא" - description: "מזהה הנושא לפרסם אליו את הדוח" - title: - label: "כותרת" - description: "כותרת הדוח" - days: - label: "ימים" - description: "פרק הזמן שמכסה הדוח" - offset: - label: "היסט" - description: "בזמן בדיקה כדאי להריץ את הדוח היסטורית, יש להשתמש בהיסט כדי להתחיל את הדוח בתאריך מוקדם יותר" - instructions: - label: "הנחיות" - description: "ההנחיות שסופקו לדגם השפה הגדול" - sample_size: - label: "גודל דגימה" - description: "מספר הפוסטים לדגימה לדוח" - tokens_per_post: - label: "אסימונים לפוסט" - description: "מספר אסימוני ה־LLM לשימוש לפוסט" - model: - label: "דגם" - description: "LLM לשימוש להפקת דוחות" - categories: - label: "קטגוריות" - description: "סינון הנושאים לקטגוריות האלו בלבד" - tags: - label: "תגיות" - description: "סינון נושאים רק לתגיות האלה" - exclude_tags: - label: "החרגת תגיות" - description: "החרגת נושאים עם התגיות האלו" - exclude_categories: - label: "החרגת קטגוריות" - description: "החרגת נושאים עם הקטגוריות האלו" - allow_secure_categories: - label: "לאפשר קטגוריות מאובטחות" - description: "לאפשר את הפקת הדוח לנושאים בקטגוריות מאובטחות" - suppress_notifications: - label: "דחיית התראות" - description: "לדחות התראות שהדוח יכול לייצר על ידי עיבוד התוכן. הגדרה זו תמפה מחדש אזכורים וקישורים פנימיים." - debug_mode: - label: "מצב ניפוי תקלות" - description: "יש להפעיל מצב ניפוי תקלות כדי לצפות בקלט והפלט הגולמיים של ה־LLM" - priority_group: - label: "קבוצת עדיפות" - description: "תיעדוף תוכן מהקבוצה הזאת בדוח" - temperature: - label: "טמפרטורה" - description: "טמפרטורה לשימוש למודל השפה הגדול (LLM), הגדלה תגדיל את האקראיות (להשאיר ריק כדי להשתמש בברירת המחדל של הדגם)" - top_p: - label: "ה־P המובילים" - description: "ה־P המוביל לשימוש למודל השפה הגדול (LLM), הגדלה תגדיל את האקראיות (להשאיר ריק כדי להשתמש בברירת המחדל של הדגם)" - llm_tool_triage: - fields: - model: - label: "מודל" - tool: - label: "כלי" - llm_persona_triage: - fields: - persona: - label: "דמות" - silent_mode: - label: "מצב שקט" - llm_triage: - fields: - system_prompt: - label: "בקשת מערכת" - description: "הבקשה שתשמש למיון ראשוני, נא לוודא שהתשובה היא מילה אחת בה ניתן להשתמש כדי להקפיץ את הפקודה" - max_post_tokens: - label: "כמות אסימונים מרבית לפוסט" - description: "מספר האסימונים המרבי לסריקה בעזרת מיון ראשוני עם LLM" - stop_sequences: - label: "עצירת רצפים" - description: "הנחיית המודל לעצור יצירת אסימונים בהגעה לערכים האלה" - search_for_text: - label: "חיפוש טקסט" - description: "אם הטקסט הבא מופיע בתגובה של ה־LLM (מודל שפה גדול), להחיל את הפעולות האלה" - category: - label: "קטגוריה" - description: "קטגוריה להחלה על הנושא" - tags: - label: "תגיות" - description: "תגיות להחלה על הנושא" - canned_reply: - label: "להגיב" - description: "טקסט גולמי של התגובות המקוננות לפוסט בנושא" - canned_reply_user: - label: "משתמש תגובה" - description: "שם המשתמש של המשתמש לפרסום התגובה המקוננת" - hide_topic: - label: "הסתרת נושא" - description: "הפיכת הנושא למוסתר בפני הציבור אם מוקפץ" - flag_type: - label: "סוג סימון" - description: "סוג הסימון להחלה על הפוסט (ספאם או לקדם לסקירה)" - flag_post: - label: "סימון פוסט" - description: "סימון פוסט (או כספאם או לסקירה)" - include_personal_messages: - label: "כולל הודעות פרטיות" - description: "לסרוק ולאמת הודעות אישיות" - reply_persona: - label: "דמות לתגובה" - model: - label: "מודל" - description: "מודל שפה שמשמש למיון ראשוני" - temperature: - label: "טמפרטורה" - description: "טמפרטורה לשימוש למודל השפה הגדול (LLM), הגדלה תגדיל את האקראיות (להשאיר ריק כדי להשתמש בברירת המחדל של הדגם)" - discourse_ai: - title: "בינה מלאכותית" - features: - short_title: "יכולות" - description: "אלו יכולות בינה מלאכותית שזמינות למבקרים באתר שלך. אפשר להגדיר אותן להשתמש בדמויות וב־LLMים (מש״גים) מסוימים ואפשר לשלוט בגישה אליהן על ידי קבוצות." - back: "חזרה" - disabled: "(כבוי)" - persona: - one: "דמות:" - two: "דמויות:" - many: "דמויות:" - other: "דמויות:" - groups: "קבוצות:" - llm: - one: "LLM/מש״ג:" - two: "LLMים:" - many: "LLMים:" - other: "LLMים:" - no_llm: "לא נבחר מש״ג/LLM" - no_persona: "לא מוגדר" - no_groups: "ללא" - edit: "עריכה" - expand_list: - one: "(עוד %{count})" - two: "(עוד %{count})" - many: "(עוד %{count})" - other: "(עוד %{count})" - collapse_list: "(להציג פחות)" - bot: - name: "בוט" - description: "צ׳אט בוט שיכול לענות על שאלות ולסייע למשתמשים בהודעות אישיות, בפורום ובצ׳אט" - filters: - all: "הכול" - text: "חיפוש יכולות, דמויות, LLMים/מש״גים או קבוצות…" - reset: "איפוס" - summarization: - name: "סיכומים" - description: "להנגיש את כפתור הסיכום שמאפשר למבקרים לסכם נושאים" - topic_summaries: "תקצירי נושאים." - search: - name: "חיפוש" - description: "שיפור חוויית החיפוש על ידי אספקת תשובות שנוצרו על ידי בינה מלאכותית לשאילתות" - discoveries: "תגליות" - embeddings: - name: "הטמעות" - discord: - name: "שילוב מול Discord" - description: "הוספת היכולת לחפש בערוצי Discord" - search: "חיפוש ב־Discord" - inference: - generate_concepts: "הסקת תפיסות" - ai_helper: - name: "מסייע" - proofread: הגהת הטקסט - title_suggestions: "הצעת כותרות" - explain: "הסבר" - illustrate_post: "איור פוסט" - smart_dates: "תאריכים חכמים" - translate: "תרגום" - markdown_tables: "יצירת טבלה ב־Markdown" - custom_prompt: "בקשה מותאמת אישית" - image_caption: "הוספת כותרות לתמונות" - translation: - description: "תרגום תוכן לשפות נתמכות" - locale_detector: "מזהה שפה ומקום" - post_raw_translator: "מתרגם פוסטים גולמי" - topic_title_translator: "מתרגם כותרות נושאים" - short_text_translator: "מתרגם טקסטים קצרים" - spam: - name: "ספאם" - modals: - select_option: "בחירת אפשרות…" - layout: - table: "טבלה" - card: "כרטיס" - spam: - short_title: "ספאם" - title: "הגדרת טיפול בספאם" - select_llm: "בחירת LLM" - custom_instructions: "הנחיות מותאמות אישית" - last_seven_days: "7 הימים האחרונים" - scanned_count: "פוסטים נסרקו" - false_positives: "סימון שגוי" - false_negatives: "ספאם שהוחמץ" - spam_detected: "ספאם זוהה" - custom_instructions_placeholder: "הנחיות נקודתיות לאתר עבור הבינה המלאכותית כדי לזהות ספאם בצורה יותר מדויקת" - enable: "הפעלה" - settings_saved: "הגדרות נשמרו" - no_llms: "אין LLMים זמינים" - test_button: "בדיקה…" - save_button: "שמירת השינויים" - test_modal: - title: "בדיקת זיהוי ספאם" - post_url_label: "כתובת או מזהה של הפוסט" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 או מזהה פוסט" - result: "תוצאה" - scan_log: "יומן סריקה" - run: "הרצת בדיקה" - spam: "ספאם" - not_spam: "לא ספאם" - errors: - scan_not_admin: - action: "תיקון" - resolved: "השגיאה נפתרה!" - usage: - short_title: "שימוש" - summary: "תקציר" - total_tokens: "סך כל האסימונים" - tokens_over_time: "סימונים לאורך זמן" - features_breakdown: "שימוש לפי יכולת" - feature: "יכולת" - usage_count: "כמות שימושים" - model: "מודל" - models_breakdown: "שימוש לפי מודל" - users_breakdown: "שימוש לפי משתמש" - all_features: "כל היכולות" - all_models: "כל המודלים" - username: "שם משתמש" - total_requests: "סך כל הבקשות" - request_tokens: "אסימוני בקשה" - response_tokens: "אסימוני תגובה" - net_request_tokens: "אסימוני בקשה מהרשת" - cached_tokens: "אסימונים שמורים במטמון" - cached_request_tokens: "אסימוני בקשה במטמון" - total_spending: "עלות משוערת" - no_users: "לא נמצאו נתוני שימוש של משתמשים" - no_models: "לא נמצאו נתוני שימוש במודל" - no_features: "לא נמצאו נתוני שימוש ביכולת" - subheader_description: "אסימונים הם היחידות הבסיסיות שבהם משתמשים LLMים כדי להבין וליצור טקסט, נתוני שימוש עשויים להשפיע על העלויות" - stat_tooltips: - total_requests: "כל הבקשות שהוגשו ל־LLMים דרך Discourse" - total_tokens: "כל האסימונים שבהם נעשה שימוש בעת שליחת בקשות ל־LLM" - request_tokens: "אסימונים שנעשה בהם שימוש כאשר ה־LLM מנסה להבין מה נאמר" - response_tokens: "אסימונים בשימוש כשה־LLM מגיב לבקשה שלך" - cached_tokens: "אסימוני בקשה שעובדו בעבר שה־LLM משתמש בהם כדי לייעל את הביצועים והעלות" - periods: - last_day: "24 השעות האחרונות" - last_week: "בשבוע שעבר" - last_month: "בחודש שעבר" - custom: "התאמה אישית…" - ai_persona: - ai_tools: "כלים" - tool_strategies: - all: "החלה על כל התגובות" - replies: - one: "החלה על התגובה הראשונה בלבד" - two: "החלה על שתי התגובות הראשונות בלבד" - many: "החלה על %{count} התגובות הראשונות בלבד" - other: "החלה על %{count} התגובות הראשונות בלבד" - back: "חזרה" - name: "שם" - edit: "עריכה" - export: "ייצוא" - description: "תיאור" - no_llm_selected: "לא נבחר דגם שפה" - use_parent_llm: "להשתמש במודל שפה עם פיצול אישיות" - max_context_posts: "כמות מרבית של פוסטים להקשר" - max_context_posts_help: "הכמות המרבית של פוסטים לשימוש כהקשר לבינה המלאכותית בעת מענה למשתמש (ריק לברירת מחדל)" - vision_enabled: ראייה מופעלת - vision_enabled_help: אם האפשרות פעילה, הבינה מלאכותית תנסה להבין תמונות שהמשתמשים מפרסמים בנושא, כתלות בתמיכה של המודל בעיבוד תמונות. נתמך על ידי המודלים העדכניים ביותר מבית Anthropic,‏ Google ו־OpenAI. - vision_max_pixels: גודל התמונות הנתמכות - vision_max_pixel_sizes: - low: איכות נמוכה - הזול ביותר (256×256) - medium: איכות בינונית (512×512) - high: איכות גבוהה - האיטית ביותר (1024×1024) - tool_details: הצגת פרטי כלי - tool_details_help: יופיעו פרטי משתמשי קצה בנוגע לאילו כלים מודל השפה הוזנק. - mentionable: לאפשר אזכורים - mentionable_help: אם האפשרות פעולה, משתמשים בקבוצות המורדות יכולים לאזכר את המשתמש הזה בפוסטים, הבינה המלאכותית תגיב בתור הדמות הזאת. - user: משתמש - create_user: יצירת משתמש - create_user_help: אפשר לצרף משתמש לדמות הזאת כרשות. אם האפשרות הזאת תסומן, הבינה המלאכותית תשתמש במשתמש הזה כדי להגיב לבקשות. - default_llm: מודל שפה כברירת מחדל - default_llm_help: דגם ברירת המחדל לשימוש לדמות הזאת. נחוץ כדי לאזכר דמות בפוסטים ציבוריים. - question_consolidator_llm: מודל שפה למגבש השאלות - question_consolidator_llm_help: מודל השפה לשימוש עבור מגבש השאלות, אפשר לבחור במודל פחות חזק כדי לחסוך בעלויות. - system_prompt: בקשת מערכת - forced_tool_strategy: אסטרטגיית כלי כפוי - allow_chat_direct_messages: "לאפשר הודעות ישירות בצ׳אט" - allow_chat_direct_messages_help: "אם האפשרות פעילה, משתמשים בקבוצות המורשות יכולים לשלוח הודעות ישירות לדמות הזאת." - allow_chat_channel_mentions: "לאפשר אזכורים בערוצי צ׳אט" - allow_chat_channel_mentions_help: "אם האפשרות פעילה, משתמשים בקבוצות המורשות יכולים לאזכר את הדמות הזאת בערוצי צ׳אט." - allow_personal_messages: "לאפשר הודעות פרטיות" - allow_personal_messages_help: "אם האפשרות פעילה, משתמשים בקבוצות המורשות יכולים לשלוח הודעות פרטיות לדמות הזאת." - allow_topic_mentions: "לאפשר אזכורים בנושאים" - allow_topic_mentions_help: "אם האפשרות פעילה, משתמשים בקבוצות המורשות יכולים לאזכר את הדמות הזאת בנושאים." - force_default_llm: "תמיד להשתמש במודל השפה כברירת מחדל" - save: "שמירה" - saved: "הדמות נשמרה" - enabled: "מאופשר?" - tools: "כלים פעילים" - forced_tools: "כלים נאכפים" - allowed_groups: "קבוצות מורשות" - confirm_delete: "למחוק את הדמות?" - new: "דמות חדשה" - no_personas: "לא יצרת דמויות עדיין" - title: "דמויות" - short_title: "דמויות" - delete: "מחיקה" - temperature: "טמפרטורה" - temperature_help: "טמפרטורה לשימוש למודל השפה הגדול (LLM), הגדלה תגדיל את היצירתיות (אפשר להשאיר ריק לשימוש בברירת המחדל של הדגם, בדרך כלל זה ערך בין 0.0 לבין 2.0)" - top_p: "ה־P המובילים" - top_p_help: "ה־P המובילים לשימוש למודל השפה הגדול (LLM), הגדלה תגדיל את היצירתיות (אפשר להשאיר ריק לשימוש בברירת המחדל של הדגם, בדרך כלל זה ערך בין 0.0 לבין 1.0)" - priority: "עדיפות" - priority_help: "דמויות בעדיפות גבוהה מוצגות למשתמשים בראש רשימת הדמויות. אם מספר דמויות הן בעדיפות הן תסודרנה לפי האלפבית." - tool_options: "אפשרויות כלי" - rag_conversation_chunks: "חיפוש בחלקי הדיון" - rag_conversation_chunks_help: "מספר הנתחים לשימוש לחיפושים עם מודל ה־RAG. הגדלה תגדיל את כמות ההקשר בו יכולה להשתמש הבינה המלאכותית." - persona_description: "דמויות הן יכולות רבות עוצמה שמאפשר להתאים את התנהגות מנוע הבינה המלאכותית בפורום הדיסקורס שלך. הן מתנהגות כמו ‚הודעות מערכת’ שמנחות את תגובות והתנהלות הבינה המלאכותית, כדי לסייע ליצור חוויית משתמש מותאמת ומקרבת יותר." - response_format: - title: "תבנית תגובת JSON" - no_format: "לא צוינה תבנית JSON" - open_modal: "עריכה" - modal: - root_title: "מבנה תגובה" - key_title: "מפתח" - examples: - title: דוגמאות - new: דוגמה חדשה - remove: מחיקת דוגמה - collapsable_title: "דוגמה מס׳ %{number}" - user: "הודעת משתמש" - model: "תגובת המודל" - list: - enabled: "בוט בינה מלאכותית?" - ai_bot: - title: "אפשרויות בוט בינה מלאכותית" - save_first: "אפשרויות בוט בינה מלאכותית נוספות תהיינה זמינות לאחר שמירת הדמות." - filters: - text: "איתור דמות" - reset: "איפוס" - no_results: "לא נמצאו דמויות שתואמות למסננים שלך." - all_features: "כל תכונה שהיא" - features_list: - one: "תכונה:" - two: "תכונות:" - many: "תכונות:" - other: "תכונות:" - llms_list: "LLM/מש״ג:" - rag: - title: "RAG" - options: - rag_chunk_tokens: "העלאת אסימוני חלקים" - rag_chunk_tokens_help: "מספר האסימונים לשימוש לכל נתח במודל ה־RAG. הגדלה תגדיל את כמות ההקשר בו יכולה להשתמש הבינה המלאכותית. (שינוי יסדר את כל ההעלאות במפתח מחדש)" - rag_chunk_overlap_tokens: "העלאת אסימוני חפיפת חלקים" - rag_chunk_overlap_tokens_help: "מספר האסימון לחפיפה בין נתחים במודל ה־RAG. (שינוי יסדר את כל ההעלאות במפתח מחדש)" - show_indexing_options: "הצגת אפשרויות העלאה" - hide_indexing_options: "הסתרת אפשרויות העלאה" - uploads: - title: "העלאות" - description: "PDF‏ (‎*.pdf), טקסט פשוט (‎.txt) או markdown‏ (‎.md)" - description_with_images: "טקסט פשוט (‎.txt), markdown‏ (‎.md), PDF‏ (‎.pdf) או תמונה (‎.png,‏ ‎.jpeg)" - button: "הוספת קבצים" - filter: "סינון העלאות" - indexed: "סודר במפתח" - indexing: "מסודר במפתח" - uploaded: "מוכן לסידור במפתח" - uploading: "בהליכי העלאה…" - remove: "הסרת העלאה" - tools: - back: "חזרה" - short_title: "כלים" - export: "ייצוא" - no_tools: "לא יצרת כלים עדיין" - name: "שם" - name_help: "השם יופיע בממשק המשתמש של Discourse והוא המזהה הקצר שבו יש להשתמש כדי למצוא את הכלי בהגדרות שונות, הוא צריך להיות ייחודי (חובה)" - new: "כלי חדש" - tool_name: "שם הכלי" - tool_name_help: "שם הכלי מוצג למודל השפה הגדול. הוא לא ייחודי, אבל הוא ייחודי לכל דמות. (הדמות מאומתת בשמירה)" - description: "תיאור" - description_help: "תיאור ברור של מטרת הכלי למודל השפה" - subheader_description: "כלים מרחיבים את היכולות של בוטים של בינה מלאכותית עם פונקציות JavaScript המוגדרות על ידי המשתמשים." - summary: "תקציר" - summary_help: "סיכום מטרת הכלים להצגה למשתמשי קצה" - script: "סקריפט" - parameters: "משתנים" - save: "שמירה" - parameter_type: "סוג משתנה" - add_parameter: "הוספת משתנה" - remove_parameter: "הסרה" - parameter_required: "נדרש" - parameter_enum: "מונה" - parameter_name: "שם משתנה" - parameter_description: "תיאור משתנה" - enum_value: "ערך מונה" - add_enum_value: "הוספת ערך מונה" - edit: "עריכה" - test: "הרצת בדיקה" - delete: "מחיקה" - saved: "הכלי נשמר" - confirm_delete: "למחוק את הכלי הזה?" - test_modal: - title: "כלי בדיקת בינה מלאכותית" - run: "הרצת בדיקה" - result: "תוצאת הבדיקה" - llms: - short_title: "LLMים" - no_llms: "אין LLMים עדיין" - new: "מודל חדש" - display_name: "שם" - name: "מזהה מודל" - provider: "ספק" - tokenizer: "מפרק לאסימונים" - max_prompt_tokens: "חלון הקשר" - max_output_tokens: "כמות אסימוני פלט מרבית" - url: "כתובת השירות שמארח את המודל" - api_key: "מפתח ה־API של השירות שמארח את המודל" - enabled_chat_bot: "לאפשר בורר בוטים של בינה מלאכותית" - vision_enabled: "ראייה מופעלת" - ai_bot_user: "משתמש בוט בינה מלאכותית" - cost_input: "עלות קלט" - cost_cached_input: "עלות קלט מאוחסן" - cost_output: "עלות פלט" - save: "שמירה" - edit: "עריכה" - saved: "מודל ה־LLM נשמר" - back: "חזרה" - confirm_delete: למחוק את המודל הזה? - delete: מחיקה - seeded_warning: "המודל הזה מוגדר מראש באתר שלך ואי אפשר לערוך אותו." - quotas: - title: "מכסות שימוש" - add_title: "יצירת מכסה חדשה" - group: "קבוצה" - max_tokens: "כמות אסימונים מרבית" - max_usages: "כמות שימושים מרבית" - duration: "משך" - confirm_delete: "למחוק את המכסה הזאת?" - add: "הוספת מכסה" - durations: - hour: "שעה" - six_hours: "6 שעות" - day: "24 שעות" - week: "7 ימים" - custom: "התאמה אישית…" - hours: "שעתיים" - max_usages_required: "חובה להגדיר אם לא הוגדרה כמות אסימונים מרבית" - usage: - ai_bot: "בוט בינה מלאכותית" - ai_helper: "מסייע" - ai_helper_image_caption: "כותרת תמונה" - ai_persona: "דמות (%{persona})" - ai_summarization: "סיכום" - ai_embeddings_semantic_search: "חיפוש בינה מלאכותית" - ai_spam: "ספאם" - automation: "אוטומציה (%{persona})" - in_use_warning: - one: "המודל הזה משמש את %{settings}. אם לא מוגדר כראוי, היכולת לא תעבוד כמצופה." - two: "המודל הזה משמש את: %{settings}. אם ההגדרות שגויות היכולת לא תעבודנה כמצופה." - many: "המודל הזה משמש את: %{settings}. אם ההגדרות שגויות היכולת לא תעבודנה כמצופה." - other: "המודל הזה משמש את: %{settings}. אם ההגדרות שגויות היכולת לא תעבודנה כמצופה." - model_description: - none: "הגדרות כלליות שעובדות עבור רוב המודלים של השפות" - anthropic-claude-opus-4-0: "המודל החכם ביותר של Anthropic" - anthropic-claude-3-5-haiku-latest: "מהיר וחסכוני" - google-gemini-2-5-flash: "קליל, מהיר וחסכוני עם עיבוד מרובה נדבכים" - google-gemini-2-0-flash-lite: "מודל חסכוני ומהיר" - open_ai-o3: "מודל ההיגיון החזק ביותר מבית OpenAI" - open_ai-o4-mini: "מודל הגיון מתקדם וחסכוני" - open_ai-gpt-4-1-nano: "מודל GPT-4.1 החסכוני והמהיר ביותר." - samba_nova-Meta-Llama-3-1-8B-Instruct: "דגם רב־לשוני, קליל ויעיל" - samba_nova-Meta-Llama-3-3-70B-Instruct": "מודל רב־תכליתי חזק" - mistral-mistral-large-latest: "המודל החזק ביותר של Mistral" - mistral-pixtral-large-latest: "המודל החזק ביותר של Mistral שבנוי לעיבוד חזותי" - preseeded_model_description: "מודל קוד פתוח שהוגדר מראש ומשתמש ב־%{model}" - configured: - title: "LLMs (מודלי שפה גדולים) מוגדרים" - preconfigured_llms: "בחירת ה־LLM שלך" - preconfigured: - title_no_llms: "נא לבחור תבנית כדי להתחיל" - title: "תבניות LLM (מודלי שפה גדולים) לא מוגדרות" - description: "מש״ג/LLM (מודלי שפה גדולים/Large Language Models) הם כלי בינה מלאכותית למשימות כגון סיכום תוכן, יצירת דוחות, אוטומציה של תקשורת מול לקוחות ופיקוח על הפורום והפקת תובנות ממנו" - fake: "הגדרות ידניות" - button: "הגדרה" - next: - title: "הבא" - tests: - title: "הרצת בדיקה" - running: "הבדיקה רצה…" - success: "הצליח!" - failure: "הניסיון ליצור קשר עם המודל החזיר את השגיאה הבאה: %{error}" - hints: - display_name: "השם משמש להתייחסות למודל הזה על פני כלל ממשק האתר שלך." - name: "אנו מצרפים את זה לקריאת ה־API כדי לציין באיזה מודל להשתמש" - vision_enabled: "אם האפשרות פעילה, הבינה מלאכותית תנסה להבין תמונות. כתלות בתמיכה של המודל בעיבוד תמונות. נתמך על ידי המודלים העדכניים ביותר מבית Anthropic,‏ Google ו־OpenAI." - enabled_chat_bot: "אם האפשרות פעילה, משתמשים יכולים לבחור את המודל הזה בעת יצירת הודעות פרטיות עם בוט הבינה המלאכותית" - cost_input: "עלות הקלט לכל מיליון אסימונים למודל זה" - cost_cached_input: "עלות הקלט המאוחסן לכל מיליון אסימונים למודל זה" - cost_output: "עלות הפלט לכל מיליון אסימונים למודל זה" - cost_measure: "$/מיליון אסימונים" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "מותאם" - provider_fields: - access_key_id: "מזהה מפתח גישה ל־AWS Bedrock" - region: "אזור של AWS Bedrock" - organization: "מזהה ארגון OpenAI כרשות" - disable_system_prompt: "השבתת הודעת מערכת בבקשות" - enable_native_tool: "הפעלת תמיכה בכלים מובנים" - disable_native_tools: "השבתת תמיכה בבלי מובנה (להשתמש בכלים מבוססי XML)" - provider_order: "סדר ספקים (רשימה מופרדת בפסיקים)" - provider_quantizations: "סדר כימות הספקים (רשימה מופרדת בפסיקים, למשל: fp16,fp8)" - reasoning_effort: "מאמץ נימוק (תקף רק על מודלים של נימוק)" - enable_reasoning: "הפעלת נימוק (תקף רק על מודלים עם אפשרות נימוק)" - enable_thinking: "הפעלת חשיבה (רק במודלים תקפים לדוגמה: flash 2.5)" - reasoning_tokens: "כמות אסימונים המשמשים לנימוק" - related_topics: - title: "נושאים קשורים" - pill: "קשורים" - ai_helper: - title: "הצעת שינויים באמצעות בינה מלאכותית" - description: "נא לבחור באחת מהאפשרויות להלן והבינה המלאכותית תציע לך גרסה חדשה של הטקסט." - selection_hint: "עצה: אפשר לבחור חלק מהטקסט בטרם פתיחת המסייע כדי לשכתב רק את החלק הזה." - suggest: "הצעה עם בינה מלאכותית" - suggest_errors: - too_many_tags: - one: "יכולה להיות לך עד תגית אחת" - two: "יכולות להיות לך עד שתי תגיות" - many: "יכולות להיות לך עד %{count} תגיות" - other: "יכולות להיות לך עד %{count} תגיות" - no_suggestions: "אין הצעות זמינות" - missing_content: "נא למלא קצת תוכן כדי לחולל הצעות." - context_menu: - trigger: "לשאול בינה מלאכותית" - loading: "הבינה המלאכותית מייצרת" - cancel: "ביטול" - regen: "ניסיון נוסף" - confirm: "אישור" - discard: "להשליך" - changes: "עריכות מוצעות" - custom_prompt: - title: "בקשה מותאמת אישית" - placeholder: "נא למלא בקשה מותאמת…" - submit: "שליחת בקשה" - translate_prompt: "תרגום ל%{language}" - post_options_menu: - trigger: "לשאול בינה מלאכותית" - title: "לשאול בינה מלאכותית" - loading: "הבינה המלאכותית נוצרת" - close: "סגירה" - copy: "העתקה" - copied: "הועתק!" - cancel: "ביטול" - insert_footnote: "הוספת הערת שוליים" - footnote_disabled: "הכנסה אוטומטית מושבתת, יש ללחוץ על כפתור ההעתקה ולערוך אותו ידנית" - footnote_credits: "הסבר של בינה מלאכותית" - fast_edit: - suggest_button: "הצעת עריכה" - thumbnail_suggestions: - title: "תמונות ממוזערות מומלצות" - select: "בחירה" - selected: "נבחרים" - image_caption: - button_label: "מתן כותרת עם בינה מלאכותית" - generating: "הכותרת מתחוללת…" - credits: "הכותרת נוספה ע״י בינה מלאכותית" - save_caption: "שמירה" - automatic_caption_setting: "הפעלת כותרת אוטומטית" - automatic_caption_loading: "נוספות כותרות לתמונות…" - automatic_caption_dialog: - prompt: "הפוסט מכיל תמונות בלי כותרת. להפעיל כותרות אוטומטיות לתמונות שנשלחות? (אפשר לשנות את זה בהעדפות שלך מאוחר יותר)" - confirm: "הפעלה" - cancel: "לא לשאול שוב" - no_content_error: "יש להוסיף קודם תוכן כדי לבצע עליו פעולות בינה מלאכותית" - reviewables: - model_used: "דגם בשימוש:" - accuracy: "דיוק:" - embeddings: - short_title: "הטמעות" - description: "הטמעות הן ייצוגים מספריים של נתונים שלוכדים משמעות וקשר, הפעלת יכולות הבינה המלאכותית של Discourse מאפשרת יכולות כגון נושאים קשורים וחיפוש בינה מלאכותית כדי להבין ולחבר תוכן." - new: "הטמעה חדשה" - back: "חזרה" - save: "שמירה" - saved: "הגדרות ההטמעה נשמרו" - delete: "מחיקה" - confirm_delete: להסיר את הגדרות ההטמעה האלה? - empty: "לא הגדרת הטמעות עדיין" - presets: "בחירת ערכה…" - configure_manually: "הגדרה ידנית" - edit: "עריכה" - seeded_warning: "מוגדר מראש באתר שלך ואי אפשר לערוך אותו." - tests: - title: "הרצת בדיקה" - running: "הבדיקה רצה…" - success: "הצליח!" - failure: "הניסיון לייצר את ההטמעות גרם ל־: %{error}" - hints: - dimensions_warning: "לאחר השמירה, אי אפשר לשנות את הערך הזה." - display_name: "שם" - provider: "ספק" - url: "כתובת שירות הטמעה" - api_key: "מפתח API לשירות הטמעה" - tokenizer: "מפרק לאסימונים" - dimensions: "ממדי הטמעה" - max_sequence_length: "אורך רצף" - embed_prompt: "בקשת הטמעה" - search_prompt: "בקשת חיפוש" - matryoshka_dimensions: "ממדי מטריושקה" - distance_function: "פונקציית מרחק" - distance_functions: - <=>: "מרחק קוסינוס" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "מותאם" - provider_fields: - model_name: "שם המודל" - semantic_search: "נושאים (סמנטיקה)" - semantic_search_loading: "מתבצע חיפוש אחר תוצאות נוספות עם בינה מלאכותית" - semantic_search_results: - toggle: "מוצגות %{count} תוצאות שנמצאו באמצעות בינה מלאכותית" - toggle_hidden: "מוסתרות %{count} תוצאות שנמצאו באמצעות בינה מלאכותית" - none: "חיפוש הבינה המלאכותית שלנו לא מצא נושאים, עמך הסליחה" - new: "יש ללחוץ על ‚חיפוש’ כדי להתחיל לחפש תוצאות חדשות עם בינה מלאכותית" - unavailable: "תוצאות בינה מלאכותית לא זמינות" - semantic_search_tooltips: - invalid_sort: "יש לסדר את תוצאות החיפוש לפי רלוונטיות להצגת תוצאות בינה מלאכותית" - semantic_search_unavailable_tooltip: "יש לסדר את תוצאות החיפוש לפי רלוונטיות להצגת תוצאות בינה מלאכותית" - ai_generated_result: "תוצאות חיפוש שנמצאו באמצעות בינה מלאכותית" - quick_search: - suffix: "בכל הנושאים והפוסטים עם בינה מלאכותית" - ai_artifact: - expand_view_label: "הרחבת תצוגה" - collapse_view_label: "יציאה ממסך מלא (כפתורי ESC או Back)" - click_to_run_label: "הרצת תוצר" - ai_bot: - persona: "דמות" - llm: "מודל" - pm_warning: "הודעות בוט שיח בינה מלאכותית לא נאכפות דרך קבע על ידי המפקחים." - cancel_streaming: "עצירת התגובה" - default_pm_prefix: "[הודעה פרטית של בינה מלאכותית ללא כותרת]" - shortcut_title: "התחלת הודעה פרטית עם בוט בינה מלאכותית" - share: "העתקת דיון עם בינה מלאכותית" - conversation_shared: "הדיון הועתק" - debug_ai: "הצגת בקשה ותגובה גולמיות לבינה המלאכותית" - sidebar_empty: "היסטוריית השיחה עם הבוט תופיע כאן." - debug_ai_modal: - title: "הצגת התכתובת מול הבינה המלאכותית" - copy_request: "העתקת הבקשה" - copy_response: "העתקת תגובה" - request_tokens: "אסימוני בקשה:" - response_tokens: "אסימוני תגובה:" - request: "בקשה" - response: "תשובה" - next_log: "הבא" - previous_log: "הקודם" - share_full_topic_modal: - title: "שיתוף שיחה באופן ציבורי" - share: "שיתוף והעתקת קישור" - update: "עדכון והעתקת קישור" - delete: "מחיקת שיתוף" - share_ai_conversation: - name: "שיתוף דיון עם בינה מלאכותית" - title: "שיתוף שיחת בינה מלאכותית באופן ציבורי" - invite_ai_conversation: - button: "להזמין" - title: "הזמנה לשיחה עם בינה מלאכותית" - ai_label: "בינה מלאכותית" - ai_title: "דיון עם בינה מלאכותית" - share_modal: - title: "העתקת דיון עם בינה מלאכותית" - copy: "העתקה" - context: "פעילויות לשיתוף:" - share_tip: "לחלופין, אפשר לשתף את כל הדיון" - bot_names: - fake: "בוט בדיקות מזויף" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT 4 טורבו" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - header: "איך אפשר לעזור?" - submit: "הגשת שאלה" - disclaimer: "בינה מלאכותית מחוללת עלולה לשגות. כדאי לאמת פרטים חשובים." - placeholder: "הגשת שאלה…" - new: "שאלה חדשה" - messages_sidebar_title: "שיחות" - today: "היום" - last_7_days: "7 הימים האחרונים" - last_30_days: "30 הימים האחרונים" - upload_files: "העלאת קבצים" - sentiments: - dashboard: - title: "רגש" - sidebar: - overview: "סקירת הבעה" - analysis: "ניתוח הבעות" - sentiment_analysis: - share_chart: "העתקת קישור לתרשים" - filter_types: - all: "הכול" - positive: "חיובי" - neutral: "נייטרלי" - negative: "שלילי" - group_types: - category: "קטגוריה" - tag: "תגית" - table: - sentiment: "רגש" - total_count: "סה״כ" - summarization: - chat: - title: "סיכום הודעות" - description: "נא לבחור אפשרות להלן כדי לסכם את הדיון שנשלח בפרק הזמן המבוקש." - summarize: "סיכום" - since: - one: "שעה אחרונה" - two: "שעתיים אחרונות" - many: "%{count} השעות האחרונות" - other: "%{count} השעות האחרונות" - topic: - title: "תקציר הנושא" - close: "סגירת חלונית תקציר" - topic_list_layout: - button: - compact: "מצומצם" - expanded: "מורחב" - expanded_description: "עם סיכוי בינה מלאכותית" - discobot_discoveries: - regular_results: "נושאים" - tell_me_more: "אשמח לדעת עוד…" - continue_convo: "המשך בשיחה…" - collapse: "צמצום" - tooltip: - header: "חיפוש מופעל בינה מלאכותית" - content: "חיפוש בשפה טבעית מופעל על גבי %{model}" - actions: - info: "איך זה עובד?" - disable: "השבתה" - user_preferences: - empty: "אין הגדרות תואמות זמינות בשלב זה" - review: - types: - reviewable_ai_post: - title: "פוסט שסומן על ידי בינה מלאכותית" - reviewable_ai_chat_message: - title: "הודעת צ׳אט שסומנה על ידי בינה מלאכותית" diff --git a/config/locales/client.hr.yml b/config/locales/client.hr.yml deleted file mode 100644 index 568550c5..00000000 --- a/config/locales/client.hr.yml +++ /dev/null @@ -1,196 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -hr: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Sortiraj po" - tag: - label: "Označiti" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID teme" - title: - label: "Naslov" - categories: - label: "Kategorije" - tags: - label: "Oznake" - llm_triage: - fields: - category: - label: "Kategorija" - tags: - label: "Oznake" - canned_reply: - label: "Odgovor" - discourse_ai: - features: - back: "Natrag" - disabled: "(onemogućeno)" - groups: "Grupe:" - no_persona: "Nije postavljeno" - no_groups: "Ništa" - edit: "Uredi" - expand_list: - one: "(%{count} još)" - few: "(%{count} još)" - other: "(%{count} još)" - collapse_list: "(prikaži manje)" - filters: - all: "Sve" - reset: "Resetirati" - search: - name: "Pretraživanje" - spam: - name: "Nepoželjno" - modals: - select_option: "Odaberi opciju..." - spam: - short_title: "Nepoželjno" - last_seven_days: "Zadnjih 7 dana" - enable: "Omogućiti" - test_modal: - spam: "Nepoželjno" - usage: - summary: "Sažetak" - username: "Korisničko ime" - total_requests: "Ukupno zahtjeva" - periods: - last_day: "Posljednja 24 sata" - custom: "Prilagođeno..." - ai_persona: - back: "Natrag" - name: "Ime" - edit: "Uredi" - export: "Izvoz" - description: "Opis" - user: Korisnik - save: "Spremi" - enabled: "Omogućeno?" - allowed_groups: "Dopuštene grupe" - delete: "Pobriši" - response_format: - open_modal: "Uredi" - modal: - key_title: "Ključ" - filters: - reset: "Resetirati" - rag: - uploads: - title: "Prijenosi" - uploading: "Učitavanje..." - tools: - back: "Natrag" - export: "Izvoz" - name: "Ime" - description: "Opis" - summary: "Sažetak" - save: "Spremi" - remove_parameter: "Ukloni" - parameter_required: "Potrebno" - edit: "Uredi" - delete: "Pobriši" - llms: - display_name: "Ime" - save: "Spremi" - edit: "Uredi" - back: "Natrag" - delete: Pobriši - quotas: - group: "Grupa" - max_usages: "Max korisnika" - duration: "Trajanje" - durations: - hour: "1 sat" - six_hours: "6 sata" - day: "24 sata" - week: "7 dana" - custom: "Prilagođeno..." - hours: "sata" - usage: - ai_summarization: "Rezimirati" - ai_spam: "Nepoželjno" - next: - title: "Sljedeći" - tests: - success: "Uspjeh!" - providers: - google: "Google" - fake: "Posebna" - ai_helper: - context_menu: - cancel: "Odustani" - confirm: "Potvrdi" - discard: "Odbaci" - post_options_menu: - close: "Zatvori" - copy: "Kopija" - copied: "Kopirano!" - cancel: "Odustani" - thumbnail_suggestions: - select: "Odaberite" - image_caption: - save_caption: "Spremi" - automatic_caption_dialog: - confirm: "Omogućiti" - embeddings: - back: "Natrag" - save: "Spremi" - delete: "Pobriši" - edit: "Uredi" - tests: - success: "Uspjeh!" - display_name: "Ime" - providers: - google: "Google" - fake: "Posebna" - ai_bot: - debug_ai_modal: - request: "Zahtjev" - response: "Odgovor" - next_log: "Sljedeći" - previous_log: "Predhodno" - invite_ai_conversation: - button: "Pozovite" - share_modal: - copy: "Kopija" - conversations: - today: "Danas" - last_7_days: "Zadnjih 7 dana" - last_30_days: "Zadnjih 30 dana" - sentiments: - sentiment_analysis: - filter_types: - all: "Sve" - group_types: - category: "Kategorija" - tag: "Označiti" - table: - total_count: "Ukupno" - summarization: - chat: - title: "Sažmi poruke" - description: "Odaberite opciju u nastavku da biste saželi razgovor poslan tijekom željenog vremenskog okvira." - summarize: "Rezimirati" - since: - one: "Posljednji sat" - few: "Posljednja %{count} sata" - other: "Posljednjih %{count} sati" - discobot_discoveries: - regular_results: "Tema" - collapse: "Sakrij" - tooltip: - actions: - disable: "Onemogući" diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml deleted file mode 100644 index faef4253..00000000 --- a/config/locales/client.hu.yml +++ /dev/null @@ -1,193 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -hu: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Rendezés" - tag: - label: "Címke" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Témaazonosító" - title: - label: "Cím" - categories: - label: "Kategóriák" - tags: - label: "Címkék" - llm_triage: - fields: - category: - label: "Kategória" - tags: - label: "Címkék" - canned_reply: - label: "Válasz" - discourse_ai: - features: - back: "Vissza" - disabled: "(kikapcsolt)" - groups: "Csoportok:" - no_persona: "Nincs beállítva" - no_groups: "Egyik sem" - edit: "Szerkesztés" - expand_list: - one: "(%{count} további)" - other: "(%{count} további)" - collapse_list: "(kevesebb megjelenítése)" - filters: - all: "Összes" - reset: "Alaphelyzetbe állítás" - search: - name: "Keresés" - spam: - name: "Kéretlen tartalom" - modals: - select_option: "Válasszon egy lehetőséget…" - spam: - short_title: "Kéretlen tartalom" - last_seven_days: "Elmúlt 7 nap" - enable: "Engedélyezés" - test_modal: - result: "Eredmény" - spam: "Kéretlen tartalom" - usage: - summary: "Összefoglaló" - username: "Felhasználónév" - total_requests: "Összes kérés" - periods: - last_day: "Elmúlt 24 óra" - custom: "Egyéni…" - ai_persona: - back: "Vissza" - name: "Név" - edit: "Szerkesztés" - export: "Exportálás" - description: "Leírás" - user: Felhasználó - save: "Mentés" - enabled: "Engedélyezve?" - delete: "Törlés" - response_format: - open_modal: "Szerkesztés" - modal: - key_title: "Kulcs" - filters: - reset: "Alaphelyzetbe állítás" - rag: - uploads: - title: "Feltöltések" - uploading: "Feltöltés…" - tools: - back: "Vissza" - export: "Exportálás" - name: "Név" - description: "Leírás" - summary: "Összefoglaló" - save: "Mentés" - remove_parameter: "Eltávolítás" - parameter_required: "Kötelező" - edit: "Szerkesztés" - delete: "Törlés" - llms: - display_name: "Név" - save: "Mentés" - edit: "Szerkesztés" - back: "Vissza" - delete: Törlés - quotas: - group: "Csoport" - max_usages: "Maximális használat" - duration: "Időtartam" - durations: - hour: "1 óra" - six_hours: "6 óra" - day: "24 óra" - week: "7 nap" - custom: "Egyéni…" - hours: "óra" - usage: - ai_summarization: "Összefoglalás" - ai_spam: "Kéretlen tartalom" - next: - title: "Tovább" - tests: - success: "Sikeres!" - providers: - google: "Google" - fake: "Egyéni" - related_topics: - pill: "Kapcsolódó" - ai_helper: - context_menu: - cancel: "Mégse" - confirm: "Megerősítés" - discard: "Elvetés" - post_options_menu: - close: "Bezárás" - copy: "Másolás" - copied: "Másolva!" - cancel: "Mégse" - thumbnail_suggestions: - selected: "Kiválasztott" - image_caption: - save_caption: "Mentés" - automatic_caption_dialog: - confirm: "Engedélyezés" - embeddings: - back: "Vissza" - save: "Mentés" - delete: "Törlés" - edit: "Szerkesztés" - tests: - title: "Teszt futtatása" - success: "Sikeres!" - display_name: "Név" - providers: - google: "Google" - fake: "Egyéni" - ai_bot: - debug_ai_modal: - request: "Kérés" - response: "Válasz" - next_log: "Tovább" - previous_log: "Vissza" - invite_ai_conversation: - button: "Meghívás" - share_modal: - copy: "Másolás" - conversations: - today: "Ma" - last_7_days: "Elmúlt 7 nap" - last_30_days: "Elmúlt 30 nap" - sentiments: - sentiment_analysis: - filter_types: - all: "Összes" - neutral: "Semleges" - group_types: - category: "Kategória" - tag: "Címke" - table: - total_count: "Összes" - summarization: - chat: - summarize: "Összefoglalás" - discobot_discoveries: - regular_results: "Témák" - collapse: "Összecsukás" - tooltip: - actions: - disable: "Letiltás" diff --git a/config/locales/client.hy.yml b/config/locales/client.hy.yml deleted file mode 100644 index 44898284..00000000 --- a/config/locales/client.hy.yml +++ /dev/null @@ -1,169 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -hy: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Դասավորել ըստ" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Թեմայի ID" - title: - label: "Վերնագիր" - categories: - label: "Կատեգորիաներ" - tags: - label: "Թեգեր" - llm_triage: - fields: - category: - label: "Կատեգորիա" - tags: - label: "Թեգեր" - canned_reply: - label: "Պատասխանել" - discourse_ai: - features: - back: "Ետ" - disabled: "(անջատված)" - groups: "Խմբեր:" - no_groups: "Ոչ մի" - edit: "Խմբագրել" - expand_list: - one: "(%{count} ավելի)" - other: "(%{count} ավելի)" - collapse_list: "(ցույց տալ ավելի քիչ)" - filters: - all: "Բոլորը" - reset: "Զրոյացնել" - search: - name: "Որոնում" - spam: - name: "Սպամ" - modals: - select_option: "Ընտրել..." - spam: - short_title: "Սպամ" - enable: "Միացնել" - test_modal: - spam: "Սպամ" - usage: - summary: "Ամփոփումը" - username: "Օգտանուն" - total_requests: "Ընդհանուր հարցումներ" - ai_persona: - back: "Ետ" - name: "Անուն" - edit: "Խմբագրել" - export: "Արտահանել" - description: "Նկարագրությունը" - user: Օգտատեր - save: "Պահպանել" - enabled: "Միացվա՞ծ է:" - delete: "Ջնջել" - response_format: - open_modal: "Խմբագրել" - modal: - key_title: "Բանալի" - filters: - reset: "Զրոյացնել" - rag: - uploads: - title: "Վերբեռնումներ" - uploading: "Վերբեռնում..." - tools: - back: "Ետ" - export: "Արտահանել" - name: "Անուն" - description: "Նկարագրությունը" - summary: "Ամփոփումը" - save: "Պահպանել" - remove_parameter: "Հեռացնել" - parameter_required: "Պարտադիր" - edit: "Խմբագրել" - delete: "Ջնջել" - llms: - display_name: "Անուն" - save: "Պահպանել" - edit: "Խմբագրել" - back: "Ետ" - delete: Ջնջել - quotas: - group: "Խմբավորել" - max_usages: "Առավելագույն օգտագործման քանակը" - durations: - hour: "1 ժամով" - six_hours: "6 ժամով" - day: "Վերջին 24 ժամվա" - week: "Վերջին 7 օրվա" - hours: "ժամ" - usage: - ai_spam: "Սպամ" - next: - title: "Հաջորդը" - tests: - success: "Հաջողություն!" - providers: - google: "Google" - fake: "Մասնավոր" - ai_helper: - context_menu: - cancel: "Չեղարկել" - discard: "Չեղարկել" - post_options_menu: - close: "Փակել" - copy: "Կրկնօրինակել" - cancel: "Չեղարկել" - image_caption: - save_caption: "Պահպանել" - automatic_caption_dialog: - confirm: "Միացնել" - embeddings: - back: "Ետ" - save: "Պահպանել" - delete: "Ջնջել" - edit: "Խմբագրել" - tests: - success: "Հաջողություն!" - display_name: "Անուն" - providers: - google: "Google" - fake: "Մասնավոր" - ai_bot: - debug_ai_modal: - request: "Հարցում" - response: "Արձագանք" - next_log: "Հաջորդը" - previous_log: "Նախորդը" - invite_ai_conversation: - button: "Հրավիրել" - share_modal: - copy: "Կրկնօրինակել" - conversations: - today: "Այսօրվա" - sentiments: - sentiment_analysis: - filter_types: - all: "Բոլորը" - neutral: "Նեյտրալ" - group_types: - category: "Կատեգորիա" - table: - total_count: "Ամբողջը" - discobot_discoveries: - regular_results: "Թեմաներ" - collapse: "Կրճատել" - tooltip: - actions: - disable: "Անջատել" diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml deleted file mode 100644 index 28e38759..00000000 --- a/config/locales/client.id.yml +++ /dev/null @@ -1,289 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -id: - admin_js: - admin: - site_settings: - categories: - discourse_ai: "Discourse" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Pengirim" - description: "Pengguna yang akan mengirim laporan" - receivers: - label: "Penerima" - description: "Pengguna yang akan menerima laporan (email akan dikirim email langsung, nama pengguna akan dikirim PM)" - topic_id: - label: "ID Topik" - title: - label: "Judul" - description: "Judul laporan" - days: - label: "Hari" - description: "Rentang waktu laporan" - offset: - label: "Offset" - description: "Saat menguji, Anda mungkin ingin mengoperasikan laporan secara historis, gunakan offset untuk memulai laporan pada tanggal yang lebih awal" - instructions: - label: "Instruksi" - description: "Instruksi diberikan kepada model bahasa besar" - sample_size: - label: "Ukuran sampel" - description: "Jumlah posting yang akan dijadikan sampel laporan" - tokens_per_post: - label: "Token per posting" - model: - label: "Model" - description: "LLM yang akan digunakan untuk pembuatan laporan" - categories: - label: "Kategori" - description: "Filter topik hanya ke kategori ini" - tags: - label: "Label" - description: "Filter topik hanya ke tag ini" - exclude_tags: - label: "Kecualikan Tag" - description: "Kecualikan topik dengan tag ini" - exclude_categories: - label: "Kecualikan Kategori" - description: "Kecualikan topik dengan kategori ini" - allow_secure_categories: - label: "Izinkan kategori aman" - description: "Izinkan laporan dibuat untuk topik dalam kategori aman" - suppress_notifications: - label: "Tiadakan Notifikasi" - description: "Tiadakan notifikasi yang mungkin dihasilkan laporan dengan mengubahnya menjadi konten. Ini akan memetakan kembali sebutan dan tautan internal." - debug_mode: - label: "Mode Debug" - description: "Aktifkan mode debug untuk melihat input dan output mentah LLM" - priority_group: - label: "Kelompok Prioritas" - description: "Prioritaskan konten dari grup ini dalam laporan" - temperature: - label: "Temperatur" - top_p: - label: "Top P" - llm_tool_triage: - fields: - model: - label: "Model" - llm_triage: - fields: - system_prompt: - label: "Perintah Sistem" - description: "Perintah yang akan digunakan untuk melakukan triase, pastikan untuk membalas dengan satu kata yang dapat Anda gunakan untuk memicu tindakan" - search_for_text: - label: "Cari teks" - category: - label: "Kategori" - description: "Kategori untuk diterapkan pada topik" - tags: - label: "Label" - canned_reply: - label: "Balas" - model: - label: "Model" - temperature: - label: "Temperatur" - discourse_ai: - title: "AI" - features: - back: "Kembali" - disabled: "(dinonaktifkan)" - groups: "Grup:" - no_groups: "Tidak ada" - edit: "Ubah" - expand_list: - other: "(%{count} lainnya)" - collapse_list: "(tampilkan lebih sedikit)" - filters: - all: "Semua" - reset: "Reset" - search: - name: "Cari" - spam: - name: "Spam" - modals: - select_option: "Memilih sebuah pilihan..." - spam: - short_title: "Spam" - last_seven_days: "7 hari terakhir" - enable: "Aktifkan" - test_modal: - spam: "Spam" - usage: - summary: "Ringkasan" - model: "Model" - username: "Nama Pengguna" - periods: - last_day: "24 jam terakhir" - ai_persona: - back: "Kembali" - name: "Nama" - edit: "Ubah" - export: "Ekspor" - description: "Deskripsi" - mentionable_help: Jika diaktifkan, pengguna di grup yang diizinkan dapat menyebut pengguna ini di posting, AI akan merespons sebagai persona ini. - user: Pengguna - question_consolidator_llm: Model Bahasa untuk Konsolidator Pertanyaan - question_consolidator_llm_help: Model bahasa yang digunakan untuk konsolidator pertanyaan, Anda dapat memilih model yang kurang kuat untuk menghemat biaya. - save: "Simpan" - enabled: "Diaktifkan?" - allowed_groups: "Grup yang diizinkan" - delete: "Hapus" - temperature: "Temperatur" - top_p: "Top P" - response_format: - open_modal: "Ubah" - filters: - reset: "Reset" - rag: - uploads: - uploading: "Mengunggah..." - tools: - back: "Kembali" - export: "Ekspor" - name: "Nama" - description: "Deskripsi" - summary: "Ringkasan" - save: "Simpan" - remove_parameter: "Hapus" - edit: "Ubah" - delete: "Hapus" - llms: - display_name: "Nama" - save: "Simpan" - edit: "Ubah" - back: "Kembali" - delete: Hapus - quotas: - duration: "Durasi" - durations: - six_hours: "6 jam" - day: "24 jam" - week: "7 hari" - hours: "jam" - usage: - ai_summarization: "Meringkas" - ai_spam: "Spam" - tests: - running: "Tes berlangsung..." - success: "Sukses!" - failure: "Mencoba menghubungi model menghasilkan kesalahan ini: %{error}" - providers: - google: "Google" - ai_helper: - missing_content: "Silakan masukkan beberapa konten untuk menghasilkan saran." - context_menu: - trigger: "Tanya AI" - loading: "AI sedang menghasilkan" - cancel: "Batal" - confirm: "Konfirmasi" - discard: "Batalkan" - custom_prompt: - placeholder: "Masukkan perintah khusus..." - post_options_menu: - trigger: "Tanya AI" - title: "Tanya AI" - loading: "AI sedang menghasilkan" - close: "Tutup" - copy: "Menyalin" - copied: "Disalin!" - cancel: "Batal" - insert_footnote: "Tambahkan catatan kaki" - footnote_credits: "Penjelasan oleh AI" - thumbnail_suggestions: - select: "Pilih" - selected: "Dipilih" - image_caption: - button_label: "Keterangan dengan AI" - generating: "Menghasilkan keterangan..." - credits: "Keterangan oleh AI" - save_caption: "Simpan" - automatic_caption_dialog: - confirm: "Aktifkan" - reviewables: - model_used: "Model yang digunakan:" - accuracy: "Akurasi:" - embeddings: - back: "Kembali" - save: "Simpan" - delete: "Hapus" - edit: "Ubah" - tests: - running: "Tes berlangsung..." - success: "Sukses!" - display_name: "Nama" - providers: - google: "Google" - semantic_search: "Topik (Semantik)" - semantic_search_loading: "Mencari hasil lebih banyak menggunakan AI" - semantic_search_results: - toggle: "Menampilkan %{count} hasil yang ditemukan menggunakan AI" - toggle_hidden: "Menyembunyikan %{count} hasil yang ditemukan menggunakan AI" - none: "Maaf, pencarian AI kami tidak menemukan topik yang cocok" - ai_generated_result: "Hasil pencarian ditemukan menggunakan AI" - quick_search: - suffix: "di semua topik dan posting dengan AI" - ai_bot: - llm: "Model" - pm_warning: "Pesan chatbot AI dipantau secara berkala oleh moderator." - cancel_streaming: "Berhenti membalas" - default_pm_prefix: "[PM bot AI tanpa judul]" - shortcut_title: "Mulai PM dengan bot AI" - share: "Salin percakapan AI" - conversation_shared: "Percakapan disalin" - debug_ai_modal: - request: "Pinta" - share_ai_conversation: - name: "Bagikan percakapan AI" - title: "Bagikan percakapan AI ini secara publik" - invite_ai_conversation: - button: "Undang" - ai_label: "AI" - ai_title: "Percakapan dengan AI" - share_modal: - title: "Salin percakapan AI" - copy: "Salin" - context: "Interaksi untuk dibagikan:" - share_tip: "Alternatifnya, Anda dapat membagikan seluruh percakapan" - bot_names: - fake: "Bot Tes Palsu" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Soneta" - claude-3-haiku: "Claude 3 Haiku" - claude-2: "Claude 2" - conversations: - last_7_days: "7 hari terakhir" - last_30_days: "30 hari terakhir" - sentiments: - sentiment_analysis: - filter_types: - all: "Semua" - group_types: - category: "Kategori" - table: - total_count: "Total" - summarization: - chat: - title: "Meringkas pesan" - description: "Pilih opsi di bawah untuk meringkas percakapan yang dikirim selama jangka waktu yang diinginkan." - summarize: "Meringkas" - since: - other: "%{count} jam terakhir" - topic: - title: "Ringkasan topik" - discobot_discoveries: - regular_results: "Topik" - collapse: "Persempit" - tooltip: - actions: - disable: "Nonaktifkan" diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml deleted file mode 100644 index 22caacb5..00000000 --- a/config/locales/client.it.yml +++ /dev/null @@ -1,686 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -it: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Consente la ricerca IA" - stream_completion: "Consente lo streaming di completamenti di personaggi IA" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "Emozione" - description: "La tabella elenca un conteggio di messaggi classificati con un'emozione determinata. Classificati con il modello 'SamLowe/roberta-base-go_emotions'." - reports: - filters: - sort_by: - label: "Ordina per" - tag: - label: "Etichetta" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Mittente" - description: "L'utente che invierà il report" - receivers: - label: "Destinatari" - description: "Gli utenti che riceveranno il report (le e-mail verranno inviate direttamente, i nomi utente verranno inviati un MP)" - topic_id: - label: "ID dell'argomento" - description: "L'ID dell'argomento in cui pubblicare il report" - title: - label: "Titolo" - description: "Il titolo del report" - days: - label: "Giorni" - description: "L'arco temporale del report" - offset: - label: "Scostamento" - description: "Durante il test, potresti voler eseguire il report in modo cronologico, utilizza lo scostamento per avviare il report in una data precedente" - instructions: - label: "Istruzioni" - description: "Le istruzioni fornite al large language model" - sample_size: - label: "Dimensione del campione" - description: "Il numero di messaggi da campionare per il report" - tokens_per_post: - label: "Token per messaggio" - description: "Il numero di token LLM da utilizzare per messaggio" - model: - label: "Modello" - description: "LLM da utilizzare per la generazione del report" - categories: - label: "Categorie" - description: "Filtra gli argomenti solo in queste categorie" - tags: - label: "Etichette" - description: "Filtra gli argomenti solo in base a queste etichette" - exclude_tags: - label: "Escludi tag" - description: "Escludi argomenti con questi tag" - exclude_categories: - label: "Escludi categorie" - description: "Escludi argomenti con queste categorie" - allow_secure_categories: - label: "Consenti categorie sicure" - description: "Consenti la generazione del report per argomenti in categorie sicure" - suppress_notifications: - label: "Evita le notifiche" - description: "Evita le notifiche che il report potrebbe generare trasformandosi in contenuto. Ciò rimapperà le menzioni e i link interni." - debug_mode: - label: "Modalità di debug" - description: "Abilita la modalità di debug per visualizzare input e output non elaborati del LLM" - priority_group: - label: "Gruppo prioritario" - description: "Dai la priorità ai contenuti di questo gruppo nel report" - temperature: - label: "Temperatura" - top_p: - label: "P superiore" - llm_tool_triage: - fields: - model: - label: "Modello" - llm_triage: - fields: - system_prompt: - label: "Comando di sistema" - description: "Il comando che verrà utilizzato per il triage, assicurati che risponda con una sola parola che puoi usare per attivare l'azione" - max_post_tokens: - label: "Numero massimo di token di messaggio" - description: "Numero massimo di token per scansionare utilizzando il triage LLM" - stop_sequences: - label: "Sequenze di arresto" - description: "Indica al modello di interrompere la generazione del token quando si arriva a uno di questi valori" - search_for_text: - label: "Cerca testo" - description: "Se nella risposta del LLM viene visualizzato il testo seguente, applica queste azioni" - category: - label: "Categoria" - description: "Categoria da applicare all'argomento" - tags: - label: "Etichette" - description: "Etichette da applicare all'argomento" - canned_reply: - label: "Rispondi" - description: "Testo non elaborato della risposta predefinita al messaggio sull'argomento" - canned_reply_user: - label: "Utente della risposta" - description: "Nome utente dell'utente che pubblicherà la risposta predefinita" - hide_topic: - label: "Nascondi argomento" - description: "Rendi l'argomento non visibile al pubblico se attivato" - flag_type: - label: "Tipo di segnalazione" - description: "Tipo di segnalazione da applicare al messaggio (spam o semplicemente passa in revisione)" - flag_post: - label: "Segnala messaggio" - description: "Segnala il post (come spam o per la revisione)" - include_personal_messages: - label: "Includi messaggi personali" - description: "Esegui anche la scansione e la selezione dei messaggi personali" - model: - label: "Modello" - description: "Modello linguistico utilizzato per il triage" - temperature: - label: "Temperatura" - discourse_ai: - title: "IA" - features: - back: "Indietro" - disabled: "(disabilitati)" - groups: "Gruppi:" - no_persona: "Non impostato" - no_groups: "Nessuna" - edit: "Modifica" - expand_list: - one: "(%{count} altri)" - other: "(%{count} altri)" - collapse_list: "(mostra meno)" - filters: - all: "Tutte" - reset: "Annulla" - search: - name: "Cerca" - embeddings: - name: "Integrazioni" - ai_helper: - name: "Assistente" - proofread: Testo corretto - explain: "Spiega" - smart_dates: "Date smart" - markdown_tables: "Genera tabella di markdown" - custom_prompt: "Comando personalizzato" - spam: - name: "Spam" - description: "Identifica lo spam potenziale utilizzando l'LLM selezionato e lo segnala ai moderatori del sito affinché lo ispezionino nella coda di revisione" - modals: - select_option: "Scegli un'opzione..." - spam: - short_title: "Spam" - title: "Configura la gestione dello spam" - select_llm: "Seleziona LLM" - custom_instructions: "Istruzioni personalizzate" - custom_instructions_help: "Istruzioni personalizzate specifiche per il tuo sito per aiutare l'intelligenza artificiale a identificare lo spam, ad esempio \"Sii più aggressivo nell'analizzare i post in una lingua diversa dall'italiano\"." - last_seven_days: "Ultimi 7 giorni" - scanned_count: "Messaggi scansionati" - false_positives: "Segnalato in modo errato" - false_negatives: "Spam mancato" - spam_detected: "Spam rilevato" - custom_instructions_placeholder: "Istruzioni specifiche del sito per l'IA per aiutare a identificare lo spam in modo più accurato" - enable: "Abilita" - spam_tip: "Il rilevamento IA dello spam analizzerà i primi 3 messaggi di tutti i nuovi utenti su argomenti pubblici. Li contrassegnerà per la revisione e bloccherà gli utenti se rappresentano verosimilmente spam." - settings_saved: "Impostazioni salvate" - spam_description: "Identifica lo spam potenziale utilizzando l'LLM selezionato e lo segnala ai moderatori del sito affinché lo ispezionino nella coda di revisione" - no_llms: "Nessun LLM disponibile" - test_button: "Test..." - save_button: "Salva le modifiche" - test_modal: - title: "Prova il rilevamento dello spam" - post_url_label: "URL o ID del messaggio" - post_url_placeholder: "https://tuo-forum.com/t/topic/123/4 oppure l'ID del messaggio" - result: "Risultato" - scan_log: "Registro di scansione" - run: "Esegui test" - spam: "Spam" - not_spam: "Non è spam" - stat_tooltips: - incorrectly_flagged: "Elementi che il bot IA ha contrassegnato come spam su cui i moderatori non erano d'accordo" - missed_spam: "Elementi segnalati dalla community come spam che non sono stati rilevati dal bot IA, con cui i moderatori hanno concordato" - errors: - scan_not_admin: - message: "Attenzione: la scansione antispam non funzionerà correttamente perché l'account di scansione antispam non è un amministratore" - action: "Correggi" - resolved: "L'errore è stato risolto!" - usage: - short_title: "Utilizzo" - summary: "Riepilogo" - total_tokens: "Totale token" - tokens_over_time: "Token nel tempo" - features_breakdown: "Utilizzo per funzionalità" - feature: "Funzionalità" - usage_count: "Conteggio utilizzo" - model: "Modello" - models_breakdown: "Utilizzo per modello" - users_breakdown: "Utilizzo per utente" - all_features: "Tutte le funzionalità" - all_models: "Tutti i modelli" - username: "Nome utente" - total_requests: "Richieste totali" - request_tokens: "Token di richiesta" - response_tokens: "Token di risposta" - net_request_tokens: "Token di richiesta netti" - cached_tokens: "Token in cache" - cached_request_tokens: "Token di richiesta memorizzati nella cache" - no_users: "Nessun dato trovato sull'utilizzo dell'utente" - no_models: "Nessun dato trovato sull'utilizzo del modello" - no_features: "Nessun dato trovato sull'utilizzo delle funzionalità" - subheader_description: "I token sono le unità di base che gli LLM utilizzano per comprendere e generare testo; i dati di utilizzo possono incidere sui costi" - stat_tooltips: - total_requests: "Tutte le richieste inoltrate agli LLM tramite Discourse" - total_tokens: "Tutti i token utilizzati quando si richiede un LLM" - request_tokens: "Token utilizzati quando l'LLM cerca di capire cosa stai dicendo" - response_tokens: "Token utilizzati quando l'LLM risponde al tuo comando" - cached_tokens: "Token di richiesta elaborati in precedenza che LLM riutilizza per ottimizzare prestazioni e costi" - periods: - last_day: "Ultime 24 ore" - last_week: "Ultima settimana" - last_month: "Ultimo mese" - custom: "Personalizza..." - ai_persona: - ai_tools: "Strumenti" - tool_strategies: - all: "Applica a tutte le risposte" - replies: - one: "Applica solo alla prima risposta" - other: "Applica alle prime %{count} risposte" - back: "Indietro" - name: "Nome" - edit: "Modifica" - export: "Esporta" - description: "Descrizione" - no_llm_selected: "Nessun modello linguistico selezionato" - max_context_posts: "Numero massimo di messaggi di contesto" - max_context_posts_help: "Il numero massimo di post da utilizzare come contesto per l'IA quando si risponde a un utente. (vuoto per impostazione predefinita)" - vision_enabled: Visione abilitata - vision_enabled_help: Se l'opzione è abilitata, l'intelligenza artificiale tenterà di comprendere le immagini che gli utenti pubblicano nell'argomento, a seconda del modello utilizzato per supportare la visione. Supportato dagli ultimi modelli di Anthropic, Google e OpenAI. - vision_max_pixels: Dimensione immagine supportata - vision_max_pixel_sizes: - low: 'Bassa qualità: più veloce (256x256)' - medium: Qualità media (512x512) - high: 'Alta qualità: più lenta (1024x1024)' - tool_details: Mostra i dettagli dello strumento - tool_details_help: Mostrerà agli utenti finali i dettagli su quali strumenti ha attivato il modello linguistico. - mentionable: Consenti menzioni - mentionable_help: Se l'opzione è abilitata, gli utenti nei gruppi consentiti possono menzionare questo utente nei post, l'IA risponderà come questa persona. - user: Utente - create_user: Crea utente - create_user_help: Facoltativamente, è possibile associare un utente a questa persona. In tal caso, l'IA utilizzerà questo utente per rispondere alle richieste. - default_llm: Modello linguistico predefinito - default_llm_help: Il modello linguistico predefinito da utilizzare per questa persona. Obbligatorio se desideri menzionare la persona nei post pubblici. - question_consolidator_llm: Modello linguistico per il consolidatore di domande - question_consolidator_llm_help: Il modello linguistico da utilizzare per il consolidatore di domande. È possibile scegliere un modello meno potente per risparmiare sui costi. - system_prompt: Comando di sistema - forced_tool_strategy: Strategia degli strumenti forzati - allow_chat_direct_messages: "Consenti messaggi diretti in chat" - allow_chat_direct_messages_help: "Se l'opzione è abilitata, gli utenti nei gruppi consentiti possono inviare messaggi diretti a questa persona." - allow_chat_channel_mentions: "Consenti menzioni nei canali di chat" - allow_chat_channel_mentions_help: "Se abilitato, gli utenti nei gruppi consentiti possono menzionare questo personaggio nei canali di chat." - allow_personal_messages: "Consenti messaggi personali" - allow_personal_messages_help: "Se l'opzione è abilitata, gli utenti nei gruppi consentiti possono inviare messaggi personali a questo personaggio." - allow_topic_mentions: "Consenti menzioni nell'argomento" - allow_topic_mentions_help: "Se abilitato, gli utenti nei gruppi consentiti possono menzionare questa persona negli argomenti." - force_default_llm: "Usa sempre il modello linguistico predefinito" - save: "Salva" - saved: "Persona salvata" - enabled: "Abilitato?" - tools: "Strumenti abilitati" - forced_tools: "Strumenti forzati" - allowed_groups: "Gruppi ammessi" - confirm_delete: "Vuoi davvero eliminare questo personaggio?" - new: "Nuovo personaggio" - no_personas: "Non hai ancora creato nessun personaggio" - title: "Personaggi" - short_title: "Personaggi" - delete: "Elimina" - temperature: "Temperatura" - temperature_help: "Temperatura da utilizzare per LLM. Aumenta per aumentare la creatività (lascia vuoto per utilizzare il modello predefinito, generalmente un valore compreso tra 0,0 e 2,0)" - top_p: "P superiore" - top_p_help: "P superiore da utilizzare per LLM, aumenta per aumentare la casualità (lascia vuoto per utilizzare l'impostazione predefinita del modello, generalmente un valore compreso tra 0,0 e 1,0)" - priority: "Priorità" - priority_help: "I personaggi prioritari vengono visualizzati agli utenti nella parte superiore dell'elenco dei personaggi. Se più personaggi hanno la priorità, verranno ordinati in ordine alfabetico." - tool_options: "Opzioni dello strumento" - rag_conversation_chunks: "Cerca blocchi di conversazione" - rag_conversation_chunks_help: "Il numero di blocchi da utilizzare per le ricerche del modello RAG. Aumenta per aumentare la quantità di contesto che l'IA può utilizzare." - persona_description: "I personaggi sono una potente funzionalità che ti consente di personalizzare il comportamento del motore IA nel tuo forum Discourse. Agiscono come un \"messaggio di sistema\" che guida le risposte e le interazioni dell'IA, aiutando a creare un'esperienza utente più personalizzata e coinvolgente." - response_format: - open_modal: "Modifica" - modal: - key_title: "Chiave" - filters: - reset: "Annulla" - rag: - options: - rag_chunk_tokens: "Carica token di blocco" - rag_chunk_tokens_help: "Il numero di token da utilizzare per ogni blocco nel modello RAG. Aumenta per aumentare la quantità di contesto che l'IA può utilizzare. (La modifica reindicizzerà tutti i caricamenti)" - rag_chunk_overlap_tokens: "Carica token di sovrapposizione di blocco" - rag_chunk_overlap_tokens_help: "Il numero di token da sovrapporre tra i blocchi nel modello RAG. (La modifica reindicizzerà tutti i caricamenti)" - show_indexing_options: "Mostra opzioni di caricamento" - hide_indexing_options: "Nascondi opzioni di caricamento" - uploads: - title: "Caricamenti" - button: "Aggiungi file" - filter: "Filtro caricamenti" - indexed: "Indicizzato" - indexing: "Indicizzazione" - uploaded: "Pronto per essere indicizzato" - uploading: "Caricamento..." - remove: "Rimuovi caricamento" - tools: - back: "Indietro" - short_title: "Strumenti" - export: "Esporta" - no_tools: "Non hai ancora creato nessuno strumento" - name: "Nome" - new: "Nuovo strumento" - description: "Descrizione" - description_help: "Una descrizione chiara dello scopo dello strumento per il modello linguistico" - subheader_description: "Gli strumenti estendono le capacità dei bot di intelligenza artificiale con funzioni JavaScript definite dall'utente." - summary: "Riepilogo" - summary_help: "Il riepilogo degli strumenti ha lo scopo di essere visualizzato agli utenti finali" - script: "Script" - parameters: "Parametri" - save: "Salva" - remove_parameter: "Rimuovi" - parameter_required: "Obbligatorie" - parameter_enum: "Enumerazione" - parameter_name: "Nome del parametro" - parameter_description: "Descrizione del parametro" - enum_value: "Valore enumerativo" - add_enum_value: "Aggiungi valore enumerativo" - edit: "Modifica" - test: "Esegui test" - delete: "Elimina" - saved: "Strumento salvato" - confirm_delete: "Vuoi davvero eliminare questo strumento?" - test_modal: - title: "Prova lo strumento IA" - run: "Esegui test" - result: "Risultato del test" - llms: - short_title: "LLM" - no_llms: "Ancora nessun LLM" - new: "Nuovo modello" - display_name: "Nome" - name: "ID modello" - provider: "Fornitore" - tokenizer: "Tokenizzatore" - url: "URL del servizio che ospita il modello" - api_key: "Chiave API del servizio che ospita il modello" - enabled_chat_bot: "Consenti selettore bot IA" - vision_enabled: "Visione abilitata" - ai_bot_user: "Utente bot IA" - save: "Salva" - edit: "Modifica" - saved: "Modello LLM salvato" - back: "Indietro" - confirm_delete: Vuoi davvero eliminare questo modello? - delete: Elimina - seeded_warning: "Questo modello è preconfigurato sul tuo sito e non può essere modificato." - quotas: - title: "Quote di utilizzo" - add_title: "Crea nuova quota" - group: "Gruppo" - max_tokens: "Numero massimo di token" - max_usages: "Limite max di utilizzi" - duration: "Durata" - confirm_delete: "Vuoi davvero eliminare questa quota?" - add: "Aggiungi quota" - durations: - hour: "1 ora" - six_hours: "6 ore" - day: "24 ore" - week: "7 giorni" - custom: "Personalizza..." - hours: "ore" - max_tokens_help: "Numero massimo di token (parole e caratteri) che ogni utente di questo gruppo può utilizzare entro la durata specificata. I token sono le unità utilizzate dai modelli IA per elaborare il testo: circa 1 token = 4 caratteri o 3/4 di parola." - max_usages_help: "Numero massimo di volte in cui ogni utente in questo gruppo può usare il modello IA entro la durata specificata. Questa quota viene tracciata per singolo utente, non condivisa tra il gruppo." - usage: - ai_bot: "Bot IA" - ai_helper: "Assistente" - ai_persona: "Personaggio (%{persona})" - ai_summarization: "Riassumi" - ai_embeddings_semantic_search: "Ricerca IA" - ai_spam: "Spam" - in_use_warning: - one: "Questo modello è attualmente utilizzato da %{settings}. Se configurato in modo errato, la funzionalità non funzionerà come previsto." - other: "Questo modello è attualmente utilizzato da quanto segue: %{settings}. Se configurato in modo errato, le funzionalità non funzioneranno come previsto. " - model_description: - none: "Impostazioni generali che vanno bene per la maggior parte dei modelli linguistici" - anthropic-claude-opus-4-0: "Il modello più intelligente di Anthropic" - anthropic-claude-3-5-haiku-latest: "Veloce e conveniente" - google-gemini-2-5-flash: "Leggero, veloce ed economico con ragionamento multimodale" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Modello multilingue leggero ed efficiente" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Potente modello multifunzionale" - mistral-mistral-large-latest: "Il modello più potente di Mistral" - mistral-pixtral-large-latest: "Il modello con capacità di visione più potente di Mistral" - preseeded_model_description: "Modello open source preconfigurato che utilizza %{model}" - configured: - title: "LLM configurati" - preconfigured_llms: "Seleziona il tuo LLM" - preconfigured: - title_no_llms: "Seleziona un modello per iniziare" - title: "Modelli LLM non configurati" - description: "Gli LLM (Large Language Models) sono strumenti di intelligenza artificiale ottimizzati per attività quali la sintesi dei contenuti, la generazione di report, l'automazione delle interazioni con i clienti e la facilitazione della moderazione e degli approfondimenti dei forum" - fake: "Configurazione manuale" - button: "Configura" - next: - title: "Avanti" - tests: - title: "Esegui test" - running: "Esecuzione del test..." - success: "Riuscito!" - failure: "Il tentativo di contattare il modello ha restituito questo errore: %{error}" - hints: - name: "Lo includiamo nella chiamata API per specificare quale modello utilizzeremo" - vision_enabled: "Se l'opzione è abilitata, l'intelligenza artificiale tenterà di comprendere le immagini. Dipende dal modello utilizzato per supportare la visione. Supportato dagli ultimi modelli di Anthropic, Google e OpenAI." - enabled_chat_bot: "Se abilitato, gli utenti possono selezionare questo modello quando creano MP con il bot IA" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Personalizzato" - provider_fields: - access_key_id: "ID chiave di accesso AWS Bedrock" - region: "Regione AWS Bedrock" - organization: "ID organizzazione OpenAI facoltativo" - disable_system_prompt: "Disabilita il messaggio di sistema nei comandi" - enable_native_tool: "Abilita il supporto degli strumenti nativi" - disable_native_tools: "Disabilita il supporto degli strumenti nativi (utilizza strumenti basati su XML)" - provider_order: "Ordine fornitori (elenco delimitato da virgole)" - provider_quantizations: "Ordine delle quantizzazioni dei fornitori (elenco delimitato da virgole, ad esempio: fp16, fp8)" - disable_streaming: "Disabilita i completamenti dello streaming (converti le richieste di streaming in richieste non di streaming)" - related_topics: - title: "Argomenti correlati" - pill: "Correlato" - ai_helper: - title: "Suggerisci modifiche utilizzando l'IA" - description: "Scegli una delle opzioni seguenti e l'IA ti suggerirà una nuova versione del testo." - selection_hint: "Suggerimento: puoi anche selezionare una parte del testo prima di aprire l'assistente per riscrivere solo quel pezzo." - suggest: "Suggerisci con l'IA" - suggest_errors: - too_many_tags: - one: "Puoi avere solo fino a %{count} etichetta" - other: "Puoi avere solo fino a %{count} etichette" - no_suggestions: "Nessun suggerimento disponibile" - missing_content: "Inserisci alcuni contenuti per generare suggerimenti." - context_menu: - trigger: "Chiedi all'IA" - loading: "L'IA sta generando" - cancel: "Annulla" - confirm: "Conferma" - discard: "Elimina" - changes: "Modifiche suggerite" - custom_prompt: - title: "Comando personalizzato" - placeholder: "Inserisci un comando personalizzato..." - submit: "Invia comando" - translate_prompt: "Traduci in %{language}" - post_options_menu: - trigger: "Chiedi all'IA" - title: "Chiedi all'IA" - loading: "L'IA sta generando" - close: "Chiudi" - copy: "Copia" - copied: "Copiato!" - cancel: "Annulla" - insert_footnote: "Aggiungi nota a piè di pagina" - footnote_disabled: "Inserimento automatico disabilitato, clicca sul pulsante Copia e modificalo manualmente" - footnote_credits: "Spiegazione dell'IA" - fast_edit: - suggest_button: "Suggerisci modifica" - thumbnail_suggestions: - title: "Miniature suggerite" - select: "Seleziona" - selected: "Selezionato" - image_caption: - button_label: "Didascalia con IA" - generating: "Generazione didascalia in corso..." - credits: "Didascalia da IA" - save_caption: "Salva" - automatic_caption_setting: "Abilita sottotitoli automatici" - automatic_caption_loading: "Generazione delle didascalie delle immagini..." - automatic_caption_dialog: - prompt: "Questo post contiene immagini senza didascalie. Desideri abilitare le didascalie automatiche sui caricamenti di immagini? (Questa opzione può essere modificata nelle tue preferenze in seguito)" - confirm: "Abilita" - cancel: "Non chiedermelo più" - no_content_error: "Aggiungi prima il contenuto per eseguire azioni IA su di esso" - reviewables: - model_used: "Modello utilizzato:" - accuracy: "Precisione:" - embeddings: - short_title: "Integrazioni" - new: "Nuova integrazione" - back: "Indietro" - save: "Salva" - saved: "Configurazione di integrazione salvata" - delete: "Elimina" - confirm_delete: Vuoi davvero rimuovere questa configurazione di integrazione? - empty: "Non hai ancora impostato le integrazioni" - presets: "Seleziona una preimpostazione..." - configure_manually: "Configura manualmente" - edit: "Modifica" - seeded_warning: "Questo elemento è preconfigurato sul tuo sito e non può essere modificato." - tests: - title: "Esegui test" - running: "Esecuzione del test..." - success: "Operazione riuscita!" - failure: "Il tentativo di generare un'integrazione ha prodotto: %{error}" - hints: - dimensions_warning: "Una volta salvato, questo valore non può essere modificato." - matryoshka_dimensions: "Definisce la dimensione delle integrazioni nidificate utilizzate per la rappresentazione gerarchica o multistrato dei dati, in modo simile a come le matrioske si inseriscono l'una nell'altra." - sequence_length: "Numero massimo di token che possono essere elaborati contemporaneamente durante la creazione di integrazioni o la gestione di una query." - distance_function: "Determina come viene calcolata la similarità tra integrazioni, utilizzando la distanza del coseno (misurando l'angolo tra i vettori) o il prodotto interno negativo (misurando la sovrapposizione dei valori dei vettori)." - display_name: "Nome" - provider: "Fornitore" - url: "URL del servizio di integrazione" - api_key: "Chiave API del servizio di integrazione" - tokenizer: "Tokenizzatore" - dimensions: "Dimensioni dell'integrazione" - max_sequence_length: "Lunghezza della sequenza" - embed_prompt: "Comando di integrazione" - search_prompt: "Comando di ricerca" - matryoshka_dimensions: "Dimensioni della matrioska" - distance_function: "Funzione di distanza" - distance_functions: - "<#>": "Prodotto interno negativo" - <=>: "Distanza del coseno" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Personalizzato" - provider_fields: - model_name: "Nome del modello" - semantic_search: "Argomenti (semantici)" - semantic_search_loading: "Ricerca di altri risultati tramite intelligenza artificiale" - semantic_search_results: - toggle: "Stai vedendo %{count} risultati trovati utilizzando l'IA" - toggle_hidden: "%{count} risultati trovati utilizzando l'IA sono stati nascosti" - none: "Spiacenti, la nostra ricerca IA non ha trovato argomenti corrispondenti" - new: "Premi \"cerca\" per iniziare a cercare nuovi risultati con l'intelligenza artificiale" - unavailable: "Risultati IA non disponibili" - semantic_search_tooltips: - results_explanation: "Se abilitata, verranno aggiunti ulteriori risultati di ricerca IA di seguito." - invalid_sort: "I risultati della ricerca devono essere ordinati in base alla pertinenza per visualizzare i risultati IA" - semantic_search_unavailable_tooltip: "I risultati della ricerca devono essere ordinati in base alla pertinenza per visualizzare i risultati IA" - ai_generated_result: "Risultato della ricerca trovato utilizzando l'intelligenza artificiale" - quick_search: - suffix: "in tutti gli argomenti e i post con IA" - ai_artifact: - expand_view_label: "Espandi vista" - collapse_view_label: "Esci dalla modalità a schermo intero (tasto ESC o Indietro)" - click_to_run_label: "Esegui artefatto" - ai_bot: - llm: "Modello" - pm_warning: "I messaggi del chatbot IA vengono controllati regolarmente dai moderatori." - cancel_streaming: "Interrompi risposta" - default_pm_prefix: "[Bot IA senza titolo MP]" - shortcut_title: "Avvia un MP con un bot IA" - share: "Copia la conversazione IA" - conversation_shared: "Conversazione copiata" - debug_ai: "Visualizza la richiesta e la risposta IA non elaborate" - debug_ai_modal: - title: "Visualizza l'interazione dell'IA" - copy_request: "Copia richiesta" - copy_response: "Copia risposta" - request_tokens: "Token richieste:" - response_tokens: "Token risposte:" - request: "Richiesta" - response: "Risposta" - next_log: "Avanti" - previous_log: "Precedente" - share_full_topic_modal: - title: "Condividi la conversazione pubblicamente" - share: "Condividi e copia il link" - update: "Aggiorna e copia il link" - delete: "Elimina condivisione" - share_ai_conversation: - name: "Condividi la conversazione con IA" - title: "Condividi pubblicamente questa conversazione IA" - invite_ai_conversation: - button: "Invita" - ai_label: "IA" - ai_title: "Conversazione con IA" - share_modal: - title: "Copia la conversazione IA" - copy: "Copia" - context: "Interazioni da condividere:" - share_tip: "In alternativa, puoi condividere l'intera conversazione" - bot_names: - fake: "Bot di prova finto" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Oggi" - last_7_days: "Ultimi 7 giorni" - last_30_days: "Ultimi 30 giorni" - sentiments: - dashboard: - title: "Sentimento" - sentiment_analysis: - filter_types: - all: "Tutte" - positive: "Positiva" - neutral: "Neutro" - negative: "Negativa" - group_types: - category: "Categoria" - tag: "Etichetta" - table: - sentiment: "Sentimento" - total_count: "Totali" - summarization: - chat: - title: "Riassumi i messaggi" - description: "Seleziona un'opzione qui sotto per riepilogare la conversazione inviata nel periodo di tempo desiderato." - summarize: "Riassumi" - since: - one: "Ultima ora" - other: "Ultime %{count} ore" - topic: - title: "Riepilogo dell'argomento" - close: "Chiudi il pannello riassuntivo" - topic_list_layout: - button: - compact: "Compatto" - expanded: "Espanso" - expanded_description: "con riepiloghi IA" - discobot_discoveries: - regular_results: "Argomenti" - collapse: "Comprimi" - tooltip: - actions: - disable: "Disattiva" - review: - types: - reviewable_ai_post: - title: "Messaggio contrassegnato da IA" - reviewable_ai_chat_message: - title: "Messaggio di chat contrassegnato da IA" diff --git a/config/locales/client.ja.yml b/config/locales/client.ja.yml deleted file mode 100644 index 1e795a3b..00000000 --- a/config/locales/client.ja.yml +++ /dev/null @@ -1,683 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ja: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "AI 検索を許可します" - stream_completion: "ストリーミング AI ペルソナの補完を許可します" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "感情" - description: "表には、特定の感情で分類された投稿のかっずがリストされます。'SamLowe/roberta-base-go_emotions' モデルで分類されます。" - reports: - filters: - sort_by: - label: "並べ替え" - tag: - label: "タグ" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "送信者" - description: "レポートを送信するユーザー" - receivers: - label: "受信者" - description: "レポートを受け取るユーザー (メールはダイレクトメールで送信され、ユーザー名に PM が送信されます)" - topic_id: - label: "トピック ID" - description: "レポートを投稿するトピック ID" - title: - label: "タイトル" - description: "レポートのタイトル" - days: - label: "日数" - description: "レポートの期間" - offset: - label: "オフセット" - description: "テスト時にレポートを過去にさかのぼって実行する必要がある場合は、オフセットを使用してレポートをより前の日付から開始します" - instructions: - label: "指示" - description: "大規模言語モデルに提供する指示" - sample_size: - label: "サンプルサイズ" - description: "レポート用にサンプリングする投稿の数" - tokens_per_post: - label: "投稿あたりのトークン" - description: "投稿あたりに使用する LLM トークンの数" - model: - label: "モデル" - description: "レポートの生成に使用する LLM" - categories: - label: "カテゴリ" - description: "トピックをこれらのカテゴリのみにフィルタします" - tags: - label: "タグ" - description: "トピックをこれらのタグのみにフィルタします" - exclude_tags: - label: "タグを除外" - description: "これらのタグが付いたトピックを除外します" - exclude_categories: - label: "カテゴリを除外" - description: "これらのカテゴリのトピックを除外します" - allow_secure_categories: - label: "安全なカテゴリを許可する" - description: "安全なカテゴリに含まれるトピックに対しレポートの生成を許可します" - suppress_notifications: - label: "通知を非表示" - description: "コンテンツに変換することでレポートが生成する可能性のある通知を非表示にします。これにより、メンションと内部リンクが再マッピングされます。" - debug_mode: - label: "デバッグモード" - description: "デバッグモードを有効にして、LLM の生の入力と出力を確認します" - priority_group: - label: "優先グループ" - description: "レポートでこのグループのコンテンツを優先します" - temperature: - label: "Temperature" - top_p: - label: "Top P" - llm_tool_triage: - fields: - model: - label: "モデル" - llm_triage: - fields: - system_prompt: - label: "システムプロンプト" - description: "トリアージに使用されるプロンプト。ユーザーがアクションのトリガーに使用できる 1 つの単語でプロンプトが応答できるようにしてください" - max_post_tokens: - label: "最大投稿トークン" - description: "LLM トリアージを使用してスキャンするトークンの最大数" - stop_sequences: - label: "シーケンスを停止" - description: "これらの値のいずれかに到達したらトークンの生成を停止するようにモデルに指示します" - search_for_text: - label: "テキストを検索する" - description: "LLM の返信に後続のテキストが表示される場合は、これらのアクションを適用します" - category: - label: "カテゴリ" - description: "トピックに適用するカテゴリ" - tags: - label: "タグ" - description: "トピックに適用するタグ" - canned_reply: - label: "返信" - description: "トピックに投稿する定型返信の生のテキスト" - canned_reply_user: - label: "ユーザーに返信" - description: "定型返信を投稿するユーザーのユーザー名" - hide_topic: - label: "トピックを非表示" - description: "トリガーされると、トピックを一般公開しないようにします" - flag_type: - label: "通報タイプ" - description: "投稿に適用する通報のタイプ (迷惑または単なるレビュー対象)" - flag_post: - label: "投稿を通報" - description: "投稿を通報 (迷惑またはレビュー対象)" - include_personal_messages: - label: "個人メッセージを含める" - description: "個人メッセージのスキャンとトリアージも行う" - model: - label: "モデル" - description: "トリアージに使用される言語モデル" - temperature: - label: "Temperature" - discourse_ai: - title: "AI" - features: - back: "戻る" - disabled: "(無効)" - persona: - other: "ペルソナ:" - groups: "グループ:" - llm: - other: "LLM:" - no_persona: "未設定" - no_groups: "なし" - edit: "編集" - expand_list: - other: "(他 %{count})" - collapse_list: "(表示を減らす)" - filters: - all: "すべて" - reset: "リセット" - search: - name: "検索" - embeddings: - name: "埋め込み" - ai_helper: - name: "ヘルパー" - proofread: テキストを構成 - explain: "説明" - smart_dates: "スマート日付" - markdown_tables: "Markdown テーブルを生成" - custom_prompt: "カスタムプロンプト" - spam: - name: "迷惑" - description: "選択された LLM を使って潜在的な迷惑行為を識別し、サイトモデレーターがレビューキューで検査できるように通報します" - modals: - select_option: "オプションを選択..." - spam: - short_title: "迷惑" - title: "迷惑処理の構成" - select_llm: "LLM を選択してください" - custom_instructions: "カスタム命令" - custom_instructions_help: "AI による迷惑行為の識別を誘導しやすくするサイトに固有のカスタム命令。例: 「英語以外の投稿をより積極的にスキャンする」。" - last_seven_days: "過去 7 日間" - scanned_count: "スキャンされた投稿数" - false_positives: "誤った通報" - false_negatives: "取り逃した迷惑行為" - spam_detected: "迷惑行為が検出されました" - custom_instructions_placeholder: "AI が迷惑行為をより正確に識別できるようにするためのサイト固有の命令" - enable: "有効化" - spam_tip: "AI 迷惑検出機能は、公開トピックのすべての新規ユーザーによる最初の 3 件の投稿をスキャンします。レビューを行うために通報し、迷惑の可能性がある場合はユーザーをブロックします。" - settings_saved: "設定が保存されました" - spam_description: "選択された LLM を使って潜在的な迷惑行為を識別し、サイトモデレーターがレビューキューで検査できるように通報します" - no_llms: "使用できる LLM はありません" - test_button: "テスト..." - save_button: "変更を保存" - test_modal: - title: "迷惑検出のテスト" - post_url_label: "投稿の URL または ID" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 または投稿 ID" - result: "結果" - scan_log: "スキャンログ" - run: "テストの実行" - spam: "迷惑" - not_spam: "迷惑ではない" - stat_tooltips: - incorrectly_flagged: "AI ボットが迷惑として通報し、モデレーターがそれに同意しなかった項目" - missed_spam: "AI ボットによって迷惑として検出されなかったが、コミュニティーは迷惑として通報し、モデレーターもそれに同意した項目" - errors: - scan_not_admin: - message: "警告: 迷惑スキャンアカウントが管理者ではないため、迷惑スキャンは正しく機能しません" - action: "修正" - resolved: "エラーは解決されました!" - usage: - short_title: "使用状況" - summary: "要約" - total_tokens: "合計トークン" - tokens_over_time: "経時的なトークン使用状況" - features_breakdown: "機能ごとの使用状況" - feature: "機能" - usage_count: "使用回数" - model: "モデル" - models_breakdown: "モデルごとの使用状況" - users_breakdown: "ユーザーごとの使用状況" - all_features: "すべての機能" - all_models: "すべてのモデル" - username: "ユーザー名" - total_requests: "合計リクエスト" - request_tokens: "リクエストトークン" - response_tokens: "レスポンストークン" - net_request_tokens: "ネットリクエストトークン" - cached_tokens: "キャッシュされたトークン" - cached_request_tokens: "キャッシュされたリクエストトークン" - no_users: "ユーザー使用状況データが見つかりません" - no_models: "モデル使用状況データが見つかりません" - no_features: "機能使用状況データが見つかりません" - stat_tooltips: - total_requests: "Discourse を通じて LLM に行われたすべてのリクエスト" - total_tokens: "LLM のプロンプトに使用されるすべてのトークン" - request_tokens: "ユーザーが述べることを LLM が理解しようとする際に使用されるトークン" - response_tokens: "LLM がプロンプトに応答する際に使用されるトークン" - cached_tokens: "LLM がパフォーマンスとコストを最適化するために再利用する、過去に処理されたリクエストトークン" - periods: - last_day: "過去 24 時間" - last_week: "先週" - last_month: "先月" - custom: "カスタム..." - ai_persona: - ai_tools: "ツール" - tool_strategies: - all: "すべての返信に適用" - replies: - other: "最初の %{count} 件の返信に適用" - back: "戻る" - name: "名前" - edit: "編集" - export: "エクスポート" - description: "説明" - no_llm_selected: "言語モデルは選択されていません" - max_context_posts: "最大コンテキスト投稿数" - max_context_posts_help: "AI がユーザーに返答するときにコンテキストとして使用する投稿の最大数。(デフォルトの場合は空白)" - vision_enabled: ビジョン対応 - vision_enabled_help: 有効にすると、AI は、ビジョンのサポートに使用されているモデルに応じてユーザーがトピックに投稿する画像を理解しようとします。Anthropic、Google、および OpenAI の最新モデルでサポートされています。 - vision_max_pixels: サポートされている画像サイズ - vision_max_pixel_sizes: - low: 低品質 - 最割安 (256x256) - medium: 中品質 (512x512) - high: 高品質 - 最も遅い (1024x1024) - tool_details: ツールの詳細を表示 - tool_details_help: 言語モデルがトリガーしたツールの詳細をエンドユーザーに表示します。 - mentionable: メンションを許可 - mentionable_help: 有効にすると、許可されているグループのユーザーは投稿内でこのユーザーをメンションでき、AI はこのペルソナとして返答します。 - user: ユーザー - create_user: ユーザーを作成 - create_user_help: オプションで、このペルソナにユーザーを関連付けられます。その場合、AI はこのユーザーを使用してリクエストに応答します。 - default_llm: デフォルトの言語モデル - default_llm_help: このペルソナに使用するデフォルトの言語モデル。公開投稿でペルソナをメンションする場合には必須です。 - question_consolidator_llm: 質問統合用の言語モデル - question_consolidator_llm_help: 質問統合に使用する言語モデル。それほど強力でないモデルを選択してコストを節約することも可能です。 - system_prompt: システムプロンプト - forced_tool_strategy: 強制されるツール戦略 - allow_chat_direct_messages: "チャットダイレクトメッセージを許可" - allow_chat_direct_messages_help: "有効にすると、許可されているグループのユーザーはこのペルソナにダイレクトメッセージを送信できます。" - allow_chat_channel_mentions: "チャットチャンネルのメンションを許可" - allow_chat_channel_mentions_help: "有効にすると、許可されたグループ内のユーザーはチャットチャンネルでこのペルソナをメンションできます。" - allow_personal_messages: "個人メッセージを許可" - allow_personal_messages_help: "有効にすると、許可されているグループのユーザーはこのペルソナに個人メッセージを送信できます。" - allow_topic_mentions: "トピックのメンションを許可" - allow_topic_mentions_help: "有効にすると、許可されたグループ内のユーザーはトピックでこのペルソナをメンションできます。" - force_default_llm: "常にデフォルトの言語モデルを使用する" - save: "保存" - saved: "ペルソナが保存されました" - enabled: "有効化?" - tools: "有効なツール" - forced_tools: "強制されたツール" - allowed_groups: "許可されたグループ" - confirm_delete: "このペルソナを削除してもよろしいですか?" - new: "新しいペルソナ" - no_personas: "ペルソナをまだ作成していません" - title: "ペルソナ" - short_title: "ペルソナ" - delete: "削除" - temperature: "Temperature" - temperature_help: "LLM に使用する Temperature。値を増やすと創造性が増加します (モデルのデフォルトを使用する場合は空白にします。一般に 0.0~2.0 の値です)" - top_p: "Top P" - top_p_help: "LLM に使用する Top P。値を増やすとランダム性が増加します (モデルのデフォルトを使用する場合は空白にします。一般に 0.0~1.0 の値です)" - priority: "優先度" - priority_help: "優先ペルソナはペルソナリストの先頭に表示されます。複数のペルソナが優先されている場合は、アルファベット順に並べ替えられます。" - tool_options: "ツールのオプション" - rag_conversation_chunks: "会話チャンクを検索" - rag_conversation_chunks_help: "RAG モデル検索に使用するチャンクの数。値を増やすと、AI が使用できるコンテキストの量が増えます。" - persona_description: "ペルソナは、Discourse フォーラムの AI エンジンの動作をカスタマイズできる強力な機能です。AI の応答と対話を誘導する「システムメッセージ」として機能し、よりパーソナライズされた魅力的なユーザーエクスペリエンスの作成に役立ちます。" - response_format: - open_modal: "編集" - modal: - key_title: "キー" - filters: - reset: "リセット" - rag: - options: - rag_chunk_tokens: "チャンクトークンをアップロード" - rag_chunk_tokens_help: "RAG モデルの各チャンクに使用するトークン数。値を増やすと、AI が使用できるコンテキストの数が増加します (変更するとすべてのアップロードのインデックスが再作成されます)" - rag_chunk_overlap_tokens: "チャンクオーバーラップトークンをアップロード" - rag_chunk_overlap_tokens_help: "RAG モデル内のチャンク間で重複するトークンの数。(変更するとすべてのアップロードのインデックスが再作成されます)" - show_indexing_options: "アップロードオプションを表示" - hide_indexing_options: "アップロードオプションを非表示" - uploads: - title: "アップロード" - button: "ファイルを追加" - filter: "アップロードをフィルタ" - indexed: "インデックスを作成しました" - indexing: "インデックス作成中" - uploaded: "インデックス作成の準備ができました" - uploading: "アップロード中..." - remove: "アップロードを削除" - tools: - back: "戻る" - short_title: "ツール" - export: "エクスポート" - no_tools: "ツールをまだ作成していません" - name: "名前" - new: "新しいツール" - description: "説明" - description_help: "言語モデルに対するツールの目的の明確な説明" - subheader_description: "ツールは、ユーザー定義の JavaScript 関数を使用して、AI ボットの機能を拡張します。" - summary: "要約" - summary_help: "エンドユーザーに表示されるツールの目的の要約" - script: "スクリプト" - parameters: "パラメーター" - save: "保存" - remove_parameter: "削除" - parameter_required: "必須" - parameter_enum: "列挙型" - parameter_name: "パラメーター名" - parameter_description: "パラメーターの説明" - enum_value: "列挙型値" - add_enum_value: "列挙型値を追加" - edit: "編集" - test: "テストを実行" - delete: "削除" - saved: "ツールが保存されました" - confirm_delete: "このツールを削除してもよろしいですか?" - test_modal: - title: "AI ツールのテスト" - run: "テストを実行" - result: "テストの結果" - llms: - short_title: "LLM" - no_llms: "まだ LLM がありません" - new: "新しいモデル" - display_name: "名前" - name: "モデル ID" - provider: "プロバイダー" - tokenizer: "トークナイザ―" - url: "モデルをホストするサービスの URL" - api_key: "モデルをホストするサービスの API キー" - enabled_chat_bot: "AI ボットのセレクターを許可" - vision_enabled: "ビジョン対応" - ai_bot_user: "AI ボットユーザー" - save: "保存" - edit: "編集" - saved: "LLM モデルが保存されました" - back: "戻る" - confirm_delete: このモデルを削除してもよろしいですか? - delete: 削除 - seeded_warning: "このモデルはサイト上で事前設定されているため、編集できません。" - quotas: - title: "使用量制限" - add_title: "新しい制限の作成" - group: "グループ" - max_tokens: "トークン上限" - max_usages: "最大使用回数" - duration: "期間" - confirm_delete: "この制限を削除してもよろしいですか?" - add: "制限を追加" - durations: - hour: "1時間" - six_hours: "6 時間" - day: "24 時間" - week: "7 日間" - custom: "カスタム..." - hours: "時間" - max_tokens_help: "このグループの各ユーザーが指定された期間内に使用できるトークン(単語と文字)の最大数。トークンは、AI モデルがテキストを処理するときに使用する単位です。およそ 1 トークン = 4 文字または 1 単語の 3/4 です。" - max_usages_help: "このグループの各ユーザーが指定された期間内に AI モデルを使用できる最大回数。この使用量制限はグループ全体で共有されるのではなく、ユーザーごとに追跡されます。" - usage: - ai_bot: "AI ボット" - ai_helper: "ヘルパー" - ai_persona: "ペルソナ (%{persona})" - ai_summarization: "要約" - ai_embeddings_semantic_search: "AI 検索" - ai_spam: "迷惑" - in_use_warning: - other: "このモデルは現在次によって使用されています: %{settings}。誤って構成されると、機能は期待どおりに動作しなくなります。" - model_description: - none: "ほとんどの言語モデルで機能する一般的な設定" - anthropic-claude-opus-4-0: "Anthropic の最もインテリジェントなモデル" - anthropic-claude-3-5-haiku-latest: "高速でコスト効率に優れています" - google-gemini-2-5-flash: "マルチモーダル推論による軽量・高速で、コスト効率に優れています" - samba_nova-Meta-Llama-3-1-8B-Instruct: "効率的な軽量多言語モデル" - samba_nova-Meta-Llama-3-3-70B-Instruct": "強力な多目的モデル" - mistral-mistral-large-latest: "Mistral の最も強力なモデル" - mistral-pixtral-large-latest: "Mistral の最も強力なビジョン対応モデル" - preseeded_model_description: "%{model} を利用する事前構成済みのオープンソースモデル" - configured: - title: "構成済みの LLM" - preconfigured_llms: "LLM を選択してください" - preconfigured: - title_no_llms: "開始するにはテンプレートを選択してください" - title: "未構成の LLM テンプレート" - fake: "手動構成" - button: "セットアップ" - next: - title: "次へ" - tests: - title: "テストの実行" - running: "テストを実行中…" - success: "成功!" - failure: "モデルに接続しようとした際に、次のエラーが返されました: %{error}" - hints: - name: "これを API 呼び出しに含めて、使用するモデルを指定します" - vision_enabled: "有効にすると、AI は画像を理解しようとします。ビジョンのサポートに使用されているモデルに応じます。Anthropic、Google、および OpenAI の最新モデルでサポートされています。" - enabled_chat_bot: "有効にすると、ユーザーは AI ボットを使って PM を作成するときに、このモデルを選択できます" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "カスタム" - provider_fields: - access_key_id: "AWS Bedrock アクセスキー ID" - region: "AWS Bedrock リージョン" - organization: "オプションの OpenAI 組織 ID" - disable_system_prompt: "プロンプトでシステムメッセージを無効にする" - enable_native_tool: "ネイティブツールのサポートを有効にする" - disable_native_tools: "ネイティブツールのサポートを無効にする (XML ベースのツールを使用する)" - provider_order: "プロバイダーの順序 (カンマ区切りリスト)" - provider_quantizations: "プロバイダーの量子化の順序 (カンマ区切りリスト 例: fp16,fp8)" - disable_streaming: "ストリーミングの完了を無効にする (ストリーミングを非ストリーミングリクエストに変換する)" - related_topics: - title: "関連トピック" - pill: "関連" - ai_helper: - title: "AI を使用して変更を提案" - description: "以下のいずれかのオプションを選択すると、AI が新しいバージョンのテキストを提案します。" - selection_hint: "ヒント: ヘルパーを開く前にテキストの一部を選択すると、その部分のみを書き換えることもできます。" - suggest: "AI で提案" - suggest_errors: - too_many_tags: - other: "最大 %{count} 個のタグのみを使用できます" - no_suggestions: "提案はありません" - missing_content: "提案を生成するにはコンテンツを入力してください。" - context_menu: - trigger: "AI に尋ねる" - loading: "AI が生成中です" - cancel: "キャンセル" - confirm: "確認" - discard: "破棄" - changes: "提案された編集" - custom_prompt: - title: "カスタムプロンプト" - placeholder: "カスタムプロンプトを入力してください..." - submit: "プロンプトを送信" - translate_prompt: "%{language} に翻訳する" - post_options_menu: - trigger: "AI に尋ねる" - title: "AI に尋ねる" - loading: "AI が生成中です" - close: "閉じる" - copy: "コピー" - copied: "コピーしました!" - cancel: "キャンセル" - insert_footnote: "脚注を追加" - footnote_disabled: "自動挿入が無効です。コピーボタンをクリックして手動で編集してください" - footnote_credits: "AI による説明" - fast_edit: - suggest_button: "編集を提案" - thumbnail_suggestions: - title: "提案されたサムネイル" - select: "選択" - selected: "選択済み" - image_caption: - button_label: "AI によるキャプション" - generating: "キャプションを生成中..." - credits: "AI によるキャプション" - save_caption: "保存" - automatic_caption_setting: "自動キャプションを有効にする" - automatic_caption_loading: "画像のキャプションを作成中..." - automatic_caption_dialog: - prompt: "この投稿にはキャプションのない画像が含まれています。画像アップロード時に、自動キャプションを有効にしますか?(これは後で設定で変更できます)" - confirm: "有効化" - cancel: "今後表示しない" - no_content_error: "先に AI アクションを実行するコンテンツを追加してください" - reviewables: - model_used: "使用モデル:" - accuracy: "精度:" - embeddings: - short_title: "埋め込み" - new: "新しい埋め込み" - back: "戻る" - save: "保存" - saved: "埋め込み構成が保存されました" - delete: "削除" - confirm_delete: この埋め込み構成を削除してもよろしいですか? - empty: "埋め込みはまだ構成されていません" - presets: "プリセットを選択..." - configure_manually: "手動で構成" - edit: "編集" - seeded_warning: "これはサイト上で事前設定されているため、編集できません。" - tests: - title: "テストを実行" - running: "テストを実行中…" - success: "成功!" - failure: "埋め込みを生成しようとした際にエラーが発生しました: %{error}" - hints: - dimensions_warning: "この値は一度保存すると変更できません。" - matryoshka_dimensions: "入れ子式の人形が相互に収まるのと同じように、データの階層または多層表示に使用されるネストされた埋め込みのサイズを定義します。" - sequence_length: "埋め込みの作成やクエリの処理時に一度に処理できるとオークンの最大数。" - distance_function: "コサイン距離(ベクトル間の角度を測定)または負の内積(ベクトル値の重なりを測定)のいずれかを使用して、埋め込み間の類似性をどのように計算するかを決定します。" - display_name: "名前" - provider: "プロバイダー" - url: "埋め込みサービス URL" - api_key: "埋め込みサービス API キー" - tokenizer: "トークナイザ―" - dimensions: "埋め込みの次元" - max_sequence_length: "シーケンスの長さ" - embed_prompt: "埋め込みプロンプト" - search_prompt: "検索プロンプト" - matryoshka_dimensions: "マトリョーシカの次元" - distance_function: "距離関数" - distance_functions: - "<#>": "負の内積" - <=>: "コサイン距離" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "カスタム" - provider_fields: - model_name: "モデル名" - semantic_search: "トピック (セマンティック)" - semantic_search_loading: "AI を使ってさらに多くの結果を検索中" - semantic_search_results: - toggle: "AI で見つかった %{count} 件の結果を表示中" - toggle_hidden: "AI で見つかった %{count} 件の結果を非表示" - none: "AI 検索ではどのトピックも一致しませんでした" - new: "「検索」を押すと、AI が新しい結果を検索し始めます" - unavailable: "AI の結果は利用できません" - semantic_search_tooltips: - results_explanation: "有効にすると、追加の AI 検索結果が下に追加されます。" - invalid_sort: "AI 結果を表示するには、検索結果を関連性で並べ替える必要があります" - semantic_search_unavailable_tooltip: "AI 結果を表示するには、検索結果を関連性で並べ替える必要があります" - ai_generated_result: "AI で見つかった検索結果" - quick_search: - suffix: "AI によるすべてのトピックと投稿" - ai_artifact: - expand_view_label: "表示を拡大" - collapse_view_label: "全画面表示を終了 (ESC または「戻る」ボタン)" - click_to_run_label: "アーティファクトを実行" - ai_bot: - llm: "モデル" - pm_warning: "AI チャットボットのメッセージは、モデレーターによって定期的に監視されます。" - cancel_streaming: "返信を停止する" - default_pm_prefix: "[無題の AI ボット PM]" - shortcut_title: "AI ボットと PM を開始する" - share: "AI の会話のコピー" - conversation_shared: "会話をコピーしました" - debug_ai: "生の AI リクエストと返答を表示する" - debug_ai_modal: - title: "AI インタラクションを表示" - copy_request: "リクエストをコピー" - copy_response: "返答をコピー" - request_tokens: "リクエストトークン:" - response_tokens: "レスポンストークン:" - request: "リクエスト" - response: "応答" - next_log: "次へ" - previous_log: "変更前" - share_full_topic_modal: - title: "会話の公開共有" - share: "共有してリンクをコピー" - update: "更新してリンクをコピー" - delete: "共有を削除" - share_ai_conversation: - name: "AI の会話を共有" - title: "この AI の会話を公開共有する" - invite_ai_conversation: - button: "招待" - ai_label: "AI" - ai_title: "AI との会話" - share_modal: - title: "AI の会話のコピー" - copy: "コピー" - context: "共有する対話:" - share_tip: "または、会話全体を共有できます" - bot_names: - fake: "偽のテストボット" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "今日" - last_7_days: "過去 7 日間" - last_30_days: "過去 30 日間" - sentiments: - dashboard: - title: "センチメント" - sentiment_analysis: - filter_types: - all: "すべて" - positive: "ポジティブ" - neutral: "ニュートラル" - negative: "ネガティブ" - group_types: - category: "カテゴリ" - tag: "タグ" - table: - sentiment: "センチメント" - total_count: "総合" - summarization: - chat: - title: "メッセージを要約する" - description: "以下から、希望する期間に送信される会話を要約するオプションを選択してください。" - summarize: "要約" - since: - other: "過去 %{count} 時間" - topic: - title: "トピックの要約" - close: "要約パネルを閉じる" - topic_list_layout: - button: - compact: "コンパクト" - expanded: "展開" - expanded_description: "AI 要約を使用" - discobot_discoveries: - regular_results: "トピック" - collapse: "折りたたむ" - tooltip: - actions: - disable: "無効化" - review: - types: - reviewable_ai_post: - title: "AI が通報した投稿" - reviewable_ai_chat_message: - title: "AI が通報したチャットメッセージ" diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml deleted file mode 100644 index ee32f8fc..00000000 --- a/config/locales/client.ko.yml +++ /dev/null @@ -1,192 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ko: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "정렬 기준" - tag: - label: "태그" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "발신자" - topic_id: - label: "토픽 ID" - title: - label: "제목" - categories: - label: "카테고리" - tags: - label: "태그" - llm_triage: - fields: - category: - label: "카테고리" - tags: - label: "태그" - canned_reply: - label: "댓글쓰기" - discourse_ai: - features: - back: "뒤로" - disabled: "(비활성)" - groups: "그룹:" - no_groups: "없음" - edit: "편집" - expand_list: - other: "(%{count}개 더보기)" - filters: - all: "전체" - reset: "리셋" - search: - name: "검색" - spam: - name: "스팸" - modals: - select_option: "옵션 선택..." - spam: - short_title: "스팸" - last_seven_days: "지난 7일" - enable: "활성화" - test_modal: - spam: "스팸" - usage: - summary: "요약" - username: "아이디" - total_requests: "총 요청횟수" - periods: - last_day: "지난 24시간" - custom: "사용자 지정..." - ai_persona: - back: "뒤로" - name: "그룹명" - edit: "편집" - export: "내보내기" - description: "내용" - user: 사용자 - save: "저장" - enabled: "활성화?" - allowed_groups: "허용 된 그룹" - delete: "삭제하기" - response_format: - open_modal: "편집" - modal: - key_title: "키" - filters: - reset: "리셋" - rag: - uploads: - title: "업로드된 파일" - uploading: "업로드 중..." - tools: - back: "뒤로" - export: "내보내기" - name: "그룹명" - description: "내용" - summary: "요약" - save: "저장" - remove_parameter: "제거" - parameter_required: "필수" - edit: "편집" - delete: "삭제하기" - llms: - display_name: "그룹명" - save: "저장" - edit: "편집" - back: "뒤로" - delete: 삭제하기 - quotas: - group: "그룹" - max_usages: "최대 사용" - duration: "기간" - durations: - hour: "1시간" - six_hours: "6시간" - day: "24시간" - custom: "사용자 지정..." - hours: "시간" - usage: - ai_summarization: "요약하기" - ai_spam: "스팸" - next: - title: "다음" - tests: - success: "성공!" - providers: - google: "구글" - fake: "사용자 정의" - ai_helper: - context_menu: - cancel: "취소" - confirm: "확인" - discard: "포기" - post_options_menu: - close: "닫기" - copy: "복사" - copied: "복사되었습니다!" - cancel: "취소" - thumbnail_suggestions: - select: "선택" - image_caption: - save_caption: "저장" - automatic_caption_dialog: - confirm: "활성화" - embeddings: - back: "뒤로" - save: "저장" - delete: "삭제하기" - edit: "편집" - tests: - success: "성공!" - display_name: "그룹명" - providers: - google: "구글" - fake: "사용자 정의" - ai_bot: - debug_ai_modal: - request: "요청" - response: "응답" - next_log: "다음" - previous_log: "이전값" - invite_ai_conversation: - button: "초대" - share_modal: - copy: "복사" - conversations: - today: "오늘" - last_7_days: "지난 7일" - last_30_days: "지난 30일" - sentiments: - sentiment_analysis: - filter_types: - all: "전체" - neutral: "중립국" - group_types: - category: "카테고리" - tag: "태그" - table: - total_count: "합계" - summarization: - chat: - title: "메시지 요약" - description: "선택된 시간 동안 전송된 대화 내역을 요약하기 위한 옵션을 선택하십시오." - summarize: "요약하기" - since: - other: "약 %{count}시간" - discobot_discoveries: - regular_results: "글" - collapse: "축소" - tooltip: - actions: - disable: "비활성화" diff --git a/config/locales/client.lt.yml b/config/locales/client.lt.yml deleted file mode 100644 index e12fcf17..00000000 --- a/config/locales/client.lt.yml +++ /dev/null @@ -1,356 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -lt: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Leidžia dirbtinio intelekto paiešką" - stream_completion: "Leidžia transliuoti dirbtinio intelekto personažų užbaigimus" - update_personas: "Leidžia atnaujinti dirbtinio intelekto personas" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "Emocija" - description: "Lentelėje pateikiamas įrašų, priskirtų konkrečiai emocijai, skaičius. Klasifikuota pagal modelį „SamLowe/roberta-base-go_emotions“." - reports: - filters: - group_by: - label: "Grupuoti pagal" - sort_by: - label: "Rūšiuoti pagal" - tag: - label: "Žymėti" - logs: - staff_actions: - actions: - create_ai_llm_model: "Sukurkite LLM modelį" - update_ai_llm_model: "Atnaujinti LLM modelį" - delete_ai_llm_model: "Ištrinti LLM modelį" - create_ai_persona: "Sukurkite AI personažą" - update_ai_persona: "Atnaujinti dirbtinio intelekto personažą" - delete_ai_persona: "Ištrinti dirbtinio intelekto personažą" - create_ai_tool: "Sukurkite dirbtinio intelekto įrankį" - update_ai_tool: "Atnaujinti dirbtinio intelekto įrankį" - delete_ai_tool: "Ištrinti dirbtinio intelekto įrankį" - create_ai_embedding: "Sukurti dirbtinio intelekto įterpimą" - update_ai_embedding: "Atnaujinti dirbtinio intelekto įterpimą" - delete_ai_embedding: "Ištrinti dirbtinio intelekto įterpimą" - update_ai_spam_settings: "Atnaujinti dirbtinio intelekto šlamšto nustatymus" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Siuntėjas" - description: "Vartotojas, kuris atsiųs ataskaitą" - receivers: - label: "Gavėjai" - description: "Vartotojai, kurie gaus ataskaitą (el. laiškai bus siunčiami tiesioginiais el. laiškais, naudotojų vardai bus išsiųsti PM)" - topic_id: - label: "Temos ID" - description: "Temos ID, į kurį reikia skelbti ataskaitą" - title: - label: "Antraštė" - description: "Pranešimo pavadinimas" - days: - label: "Dienos" - description: "Ataskaitos laikotarpis" - offset: - label: "Poslinkis" - description: "Testuodami ataskaitą galite paleisti senesne data, naudodami poslinkį, kad ataskaita būtų pradėta anksčiau." - instructions: - label: "Instrukcijos" - description: "Didelio kalbos modelio instrukcijos" - sample_size: - label: "Pavyzdžio dydis" - description: "Įrašų, į kuriuos reikia įtraukti ataskaitą, skaičius" - tokens_per_post: - label: "Žetonų už įrašą" - description: "LLM žetonų, kuriuos galima naudoti vienam įrašui, skaičius" - model: - label: "Modelis" - description: "LLM, naudojama ataskaitų generavimui" - categories: - label: "Kategorijos" - description: "Filtruoti temas tik pagal šias kategorijas" - tags: - label: "Žymos" - description: "Filtruoti temas tik pagal šias žymas" - exclude_tags: - label: "Išskirti žymas" - description: "Išskirti temas su šiomis žymėmis" - exclude_categories: - label: "Išskirti kategorijas" - description: "Išskirti temas su šiomis kategorijomis" - allow_secure_categories: - label: "Leisti saugias kategorijas" - description: "Leisti generuoti ataskaitą temoms, esančioms saugiose kategorijose" - suppress_notifications: - label: "Slėpti pranešimus" - description: "Neleisti ataskaitos generuojamų pranešimų, transformuojant juos į turinį. Tai pakeis paminėjimų ir vidinių nuorodų susiejimą." - debug_mode: - label: "Derinimo režimas" - description: "Įjunkite derinimo režimą, kad matytumėte neapdorotus LLM įvesties ir išvesties duomenis" - priority_group: - label: "Pagrindinė Grupė" - description: "Ataskaitoje teikti pirmenybę šios grupės turiniui" - temperature: - label: "Temperatūra" - description: "Temperatūra, kurią naudosite LLM. Padidinkite, kad padidintumėte atsitiktinumą (palikite tuščią, kad naudotumėte numatytuosius modelio parametrus)." - top_p: - label: "Viršutinė P" - description: "Viršutinė P, naudojama LLM, padidinkite, kad padidėtų atsitiktinumas (palikite tuščią, jei norite naudoti numatytuosius modelio parametrus)" - llm_tool_triage: - fields: - model: - label: "Modelis" - description: "Numatytasis kalbos modelis, naudojamas triažui" - tool: - label: "Įrankis" - description: "Įrankis, naudojamas triažui (įrankis negali turėti apibrėžtų parametrų)" - llm_persona_triage: - fields: - persona: - label: "Persona" - llm_triage: - fields: - system_prompt: - label: "Sistemos pranešimas" - description: "Pranešimas, kuris bus naudojamas nustatant, įsitikinkite, kad jis turi atsakyti vienu žodžiu, kurį galite naudoti veiksmui suaktyvinti" - search_for_text: - label: "Ieškokite teksto" - category: - label: "Kategorija" - description: "Kategorija, kuriai taikoma tema" - tags: - label: "Žymos" - description: "Žymos, kuriai taikoma tema" - canned_reply: - label: "Atsakyti" - canned_reply_user: - label: "Atsakyti Vartotojas" - description: "Vartotojo vardas, norintis paskelbti paruoštą atsakymą" - hide_topic: - label: "Slėpti temą" - model: - label: "Modelis" - discourse_ai: - title: "AI" - features: - back: "Atgal" - disabled: "(uždrausta)" - groups: "Grupės:" - no_groups: "Nieko" - edit: "Redaguoti" - expand_list: - one: "(%{count} daugiau)" - few: "(%{count}daugiau)" - many: "(%{count}daugiau)" - other: "(%{count} daugiau)" - collapse_list: "(rodyti mažiau)" - filters: - all: "Visos" - reset: "Atstatyti" - search: - name: "Paieška" - inference: - match_concepts: "Sąvokų atitikimas" - deduplicate_concepts: "Sąvokų deduplikacija" - ai_helper: - name: "Padėjėjas" - description: "Padeda vartotojams bendrauti bendruomenėje, pavyzdžiui, kurti temas, rašyti įrašus ir skaityti turinį" - proofread: Korektūruotas tekstas - title_suggestions: "Siūlyti pavadinimus" - explain: "Paaiškinkite" - illustrate_post: "Iliustruoti įrašą" - smart_dates: "Išmaniosios datos" - translate: "Versti" - markdown_tables: "Sukurti Markdown lentelę" - custom_prompt: "Pasirinktinis raginimas" - image_caption: "Antraštės vaizdai" - translation: - name: "Vertimai" - description: "Verčia turinį į palaikomas kalbas" - locale_detector: "Lokalės detektorius" - post_raw_translator: "Neapdoroto įrašo vertėjas" - topic_title_translator: "Temos pavadinimo vertėjas" - short_text_translator: "Trumpų tekstų vertėjas" - spam: - name: "Šlamštas" - description: "Naudodamas pasirinktą LLM, identifikuoja galimą šlamštą ir pažymi jį svetainės moderatoriams, kad jie galėtų jį patikrinti peržiūros eilėje." - inspect_posts: "Apžiūrėti įrašus" - modals: - select_option: "Pasirink nustatymą..." - layout: - table: "Lentelė" - card: "Kortelė" - spam: - short_title: "Šlamštas" - title: "Šlamšto tvarkymo konfigūravimas" - select_llm: "Pasirinkite teisės magistro laipsnį (LLM)" - select_persona: "Pasirinkite asmenį" - custom_instructions: "Individualūs nurodymai" - custom_instructions_help: "Jūsų svetainei pritaikytos instrukcijos, padedančios dirbtiniam intelektui atpažinti šlamštą, pvz., „Aktyviau nuskaitykite įrašus ne anglų kalba“." - last_seven_days: "Paskutinės 7 dienos" - scanned_count: "Įrašai nuskaityti" - false_positives: "Neteisingai pažymėta" - false_negatives: "Praleistas šlamštas" - spam_detected: "Aptiktas šlamštas" - custom_instructions_placeholder: "Svetainėms skirtos instrukcijos dirbtiniam intelektui, padedančios tiksliau atpažinti šlamštą" - enable: "Įgalinti" - spam_tip: "Dirbtinio intelekto šlamšto aptikimo funkcija nuskaitys pirmuosius 3 visų naujų vartotojų įrašus viešose temose. Jie bus pažymėti peržiūrai ir užblokuoti vartotojus, jei jie greičiausiai yra šlamštas." - settings_saved: "Nustatymai išsaugoti" - spam_description: "Naudodamas pasirinktą LLM, identifikuoja galimą šlamštą ir pažymi jį svetainės moderatoriams, kad jie galėtų jį patikrinti peržiūros eilėje." - no_llms: "Nėra laisvų teisės magistro laipsnių (LLM)." - test_button: "Bandymas..." - save_button: "Išsaugoti pakeitimus" - test_modal: - title: "Šlamšto aptikimo bandymas" - post_url_label: "Įrašo URL arba ID" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 arba įrašo ID" - result: "Rezultatas" - scan_log: "Nuskaitymo žurnalas" - spam: "Šlamštas" - usage: - summary: "Santrauka" - model: "Modelis" - username: "Vartotojo vardas" - total_requests: "Visos užklausos" - periods: - last_day: "Paskutinės 24 valandos" - custom: "Pasirinktinis..." - ai_persona: - back: "Atgal" - name: "Vardas" - edit: "Redaguoti" - export: "Eksportuoti" - description: "Aprašymas" - user: Narys - save: "Išsaugoti" - enabled: "Galimas?" - delete: "Pašalinti" - response_format: - open_modal: "Redaguoti" - modal: - key_title: "Raktas" - filters: - reset: "Atstatyti" - rag: - uploads: - title: "Įkėlimai" - uploading: "Įkeliama" - tools: - back: "Atgal" - export: "Eksportuoti" - name: "Vardas" - description: "Aprašymas" - summary: "Santrauka" - save: "Išsaugoti" - remove_parameter: "Pašalinti" - parameter_required: "Privalomi" - edit: "Redaguoti" - delete: "Pašalinti" - llms: - display_name: "Vardas" - save: "Išsaugoti" - edit: "Redaguoti" - back: "Atgal" - delete: Pašalinti - quotas: - group: "Grupė" - max_usages: "Maksimaliai naudoja" - duration: "Trukmė" - durations: - hour: "1 valanda" - six_hours: "6 valandos" - day: "24 valandos" - custom: "Pasirinktinis..." - hours: "valandos" - usage: - ai_summarization: "Apibendrinti" - ai_spam: "Šlamštas" - next: - title: "Kitas" - tests: - success: "Sėkmingai!" - providers: - google: "Google" - fake: "Išskirtinės" - ai_helper: - context_menu: - trigger: "Paklauskite AI" - cancel: "Atšaukti" - confirm: "Patvirtinti" - discard: "Išmesti" - post_options_menu: - trigger: "Paklauskite AI" - title: "Paklauskite AI" - close: "Uždaryti" - copy: "Kopijuoti" - copied: "Nukopijuota!" - cancel: "Atšaukti" - image_caption: - button_label: "Antraštė su AI" - generating: "Generuojama antraštė..." - credits: "Antraštė teikiama AI" - save_caption: "Išsaugoti" - automatic_caption_dialog: - confirm: "Įgalinti" - embeddings: - back: "Atgal" - save: "Išsaugoti" - delete: "Pašalinti" - edit: "Redaguoti" - tests: - success: "Sėkmingai!" - display_name: "Vardas" - providers: - google: "Google" - fake: "Išskirtinės" - ai_bot: - llm: "Modelis" - debug_ai_modal: - request: "Užklausa" - response: "Atsakymas" - next_log: "Kitas" - previous_log: "Buvęs" - invite_ai_conversation: - button: "Kviesti" - ai_label: "AI" - share_modal: - copy: "Kopijuoti" - bot_names: - claude-2: "Claude 2" - conversations: - today: "Šiandien" - last_7_days: "Paskutinės 7 dienos" - last_30_days: "Paskutinės 30 dienų" - sentiments: - sentiment_analysis: - filter_types: - all: "Visos" - neutral: "Neutrali" - group_types: - category: "Kategorija" - tag: "Žymėti" - table: - total_count: "Viso" - summarization: - chat: - summarize: "Apibendrinti" - discobot_discoveries: - regular_results: "Temos" - collapse: "Suskleisti" - tooltip: - actions: - disable: "Išjungti" diff --git a/config/locales/client.lv.yml b/config/locales/client.lv.yml deleted file mode 100644 index a42fce33..00000000 --- a/config/locales/client.lv.yml +++ /dev/null @@ -1,165 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -lv: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Kārtot pēc" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Tēmas ID" - title: - label: "Virsraksts" - categories: - label: "Sadaļas" - tags: - label: "Birkas" - llm_triage: - fields: - category: - label: "Sadaļa" - tags: - label: "Birkas" - canned_reply: - label: "Atbilde" - discourse_ai: - features: - back: "Atpakaļ" - disabled: "(atslēgt)" - groups: "Grupas:" - no_groups: "Nav" - edit: "Rediģēt" - expand_list: - zero: "(vēl %{count})" - one: "(vēl %{count})" - other: "(vēl %{count})" - filters: - all: "Viss" - reset: "Atlikt" - search: - name: "Meklēt" - spam: - name: "Spams" - modals: - select_option: "Izvēlieties opciju..." - spam: - short_title: "Spams" - last_seven_days: "Pēdējās 7 dienas" - enable: "Ieslēgt" - test_modal: - spam: "Spams" - usage: - summary: "Kopsavilkums" - username: "Lietotājvārds" - periods: - last_day: "Pēdējās 24 stundas" - ai_persona: - back: "Atpakaļ" - name: "Vārds" - edit: "Rediģēt" - export: "Eksportēt" - description: "Apraksts" - user: Lietotājs - save: "Saglabāt" - enabled: "Ieslēgts?" - delete: "Dzēst" - response_format: - open_modal: "Rediģēt" - filters: - reset: "Atlikt" - rag: - uploads: - title: "Augšupielādes" - uploading: "Notiek ielāde..." - tools: - back: "Atpakaļ" - export: "Eksportēt" - name: "Vārds" - description: "Apraksts" - summary: "Kopsavilkums" - save: "Saglabāt" - remove_parameter: "Atcelt" - parameter_required: "Nepieciešams" - edit: "Rediģēt" - delete: "Dzēst" - llms: - display_name: "Vārds" - save: "Saglabāt" - edit: "Rediģēt" - back: "Atpakaļ" - delete: Dzēst - quotas: - group: "Grupa" - max_usages: "Maksimāli izmantojams" - duration: "Ilgums" - hours: "stunda" - usage: - ai_spam: "Spams" - next: - title: "Nākamais" - tests: - success: "Veiksmīgi!" - providers: - google: "Google" - ai_helper: - context_menu: - cancel: "Atcelt" - discard: "Izmest" - post_options_menu: - close: "Aizvērt" - copy: "Kopēt" - copied: "Nokopēts!" - cancel: "Atcelt" - image_caption: - save_caption: "Saglabāt" - automatic_caption_dialog: - confirm: "Ieslēgt" - embeddings: - back: "Atpakaļ" - save: "Saglabāt" - delete: "Dzēst" - edit: "Rediģēt" - tests: - success: "Veiksmīgi!" - display_name: "Vārds" - providers: - google: "Google" - ai_bot: - debug_ai_modal: - request: "Pieprasījums" - response: "Atbilde" - next_log: "Nākamais" - previous_log: "Iepriekšējais" - invite_ai_conversation: - button: "Ielūgt" - share_modal: - copy: "Kopēt" - conversations: - today: "Šodien" - last_7_days: "Pēdējās 7 dienas" - last_30_days: "Pēdējās 30 dienas" - sentiments: - sentiment_analysis: - filter_types: - all: "Viss" - group_types: - category: "Sadaļa" - table: - total_count: "Kopā" - discobot_discoveries: - regular_results: "Tēmas" - collapse: "Sakļaut" - tooltip: - actions: - disable: "Atslēgt" diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml deleted file mode 100644 index 5267bb74..00000000 --- a/config/locales/client.nb_NO.yml +++ /dev/null @@ -1,175 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -nb_NO: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Sorter etter" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Emne-ID" - title: - label: "Tittel" - categories: - label: "Kategorier" - tags: - label: "Stikkord" - llm_triage: - fields: - category: - label: "Kategori" - tags: - label: "Stikkord" - canned_reply: - label: "Svar" - discourse_ai: - features: - back: "Forrige" - disabled: "(deaktivert)" - groups: "Grupper:" - no_persona: "Ikke angitt" - no_groups: "Ingen" - edit: "Endre" - expand_list: - one: "(%{count} mer)" - other: "(%{count} mer)" - collapse_list: "(vis mindre)" - filters: - all: "Alle" - reset: "Tilbakestill" - search: - name: "Søk" - spam: - name: "Spam" - modals: - select_option: "Velg et alternativ..." - spam: - short_title: "Spam" - last_seven_days: "Siste 7 dager" - enable: "Aktiver" - test_modal: - spam: "Spam" - usage: - summary: "Sammendrag" - username: "Brukernavn" - total_requests: "Totalt forespørsler" - periods: - last_day: "Siste 24 timer" - ai_persona: - back: "Forrige" - name: "Navn" - edit: "Endre" - export: "Eksporter" - description: "Beskrivelse" - user: Bruker - save: "Lagre" - enabled: "Aktivert?" - delete: "Slett" - response_format: - open_modal: "Endre" - filters: - reset: "Tilbakestill" - rag: - uploads: - title: "Opplastinger" - uploading: "Laster opp…" - tools: - back: "Forrige" - export: "Eksporter" - name: "Navn" - description: "Beskrivelse" - summary: "Sammendrag" - save: "Lagre" - remove_parameter: "Fjern" - parameter_required: "Påkrevd" - edit: "Endre" - delete: "Slett" - llms: - display_name: "Navn" - save: "Lagre" - edit: "Endre" - back: "Forrige" - delete: Slett - quotas: - group: "Gruppe" - max_usages: "Maks antall bruk" - duration: "Varighet" - durations: - hour: "1 time" - six_hours: "6 timer" - day: "24 timer" - week: "7 dager" - hours: "timer" - usage: - ai_summarization: "Oppsummer" - ai_spam: "Spam" - next: - title: "Neste" - tests: - success: "Suksess!" - providers: - google: "Google" - fake: "Egendefinert" - ai_helper: - context_menu: - cancel: "Avbryt" - discard: "Forkast" - post_options_menu: - close: "Lukk" - copy: "Kopier" - copied: "Kopiert!" - cancel: "Avbryt" - image_caption: - save_caption: "Lagre" - automatic_caption_dialog: - confirm: "Aktiver" - embeddings: - back: "Forrige" - save: "Lagre" - delete: "Slett" - edit: "Endre" - tests: - success: "Suksess!" - display_name: "Navn" - providers: - google: "Google" - fake: "Egendefinert" - ai_bot: - debug_ai_modal: - request: "Forespørsel" - response: "Svar" - next_log: "Neste" - previous_log: "Forrige" - invite_ai_conversation: - button: "Inviter" - share_modal: - copy: "Kopier" - conversations: - today: "I dag" - last_7_days: "Siste 7 dager" - last_30_days: "Siste 30 dager" - sentiments: - sentiment_analysis: - filter_types: - all: "Alle" - group_types: - category: "Kategori" - table: - total_count: "Total" - discobot_discoveries: - regular_results: "Emner" - collapse: "Fold sammen" - tooltip: - actions: - disable: "Deaktiver" diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml deleted file mode 100644 index 75540759..00000000 --- a/config/locales/client.nl.yml +++ /dev/null @@ -1,686 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -nl: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Staat AI zoeken toe" - stream_completion: "Maakt streamen van voltooiingen van AI persona's mogelijk" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "Emotie" - description: "De tabel bevat een telling van berichten die zijn geclassificeerd met een bepaalde emotie. Geclassificeerd met het model 'SamLowe/roberta-base-go_emotions'." - reports: - filters: - sort_by: - label: "Sorteren op" - tag: - label: "Taggen" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Afzender" - description: "De gebruiker die het rapport verzendt" - receivers: - label: "Ontvangers" - description: "De gebruikers die het rapport ontvangen (e-mails worden rechtstreeks verzonden, gebruikersnamen ontvangen een PB)" - topic_id: - label: "Topic-ID" - description: "De ID van het topic waarin het rapport moet worden geplaatst" - title: - label: "Titel" - description: "De titel van het rapport" - days: - label: "Dagen" - description: "Het tijdsbestek van het rapport" - offset: - label: "Offset" - description: "Bij het testen wil je het rapport wellicht historisch uitvoeren. Gebruik offset om het rapport op een eerdere datum te starten" - instructions: - label: "Instructies" - description: "De instructies voor het grote taalmodel" - sample_size: - label: "Monstergrootte" - description: "Het aantal berichten om te gebruiken voor het rapport" - tokens_per_post: - label: "Tokens per bericht" - description: "Het aantal LLM-tokens dat per bericht moet worden gebruikt" - model: - label: "Model" - description: "Te gebruiken LLM voor het genereren van het rapport" - categories: - label: "Categorieën" - description: "Filter topics alleen op deze categorieën" - tags: - label: "Tags" - description: "Filter topics alleen op deze tags" - exclude_tags: - label: "Tags uitsluiten" - description: "Sluit topics met deze tags uit" - exclude_categories: - label: "Categorieën uitsluiten" - description: "Sluit topics met deze categorieën uit" - allow_secure_categories: - label: "Beveiligde categorieën toestaan" - description: "Sta toe dat het rapport wordt gegenereerd voor topics in beveiligde categorieën" - suppress_notifications: - label: "Meldingen onderdrukken" - description: "Onderdruk meldingen die het rapport kan genereren door ze om te zetten in inhoud. Hierdoor worden vermeldingen en interne links opnieuw toegewezen." - debug_mode: - label: "Foutopsporingsmodus" - description: "Schakel de foutopsporingsmodus in om de onbewerkte in- en uitvoer van het LLM te bekijken" - priority_group: - label: "Prioriteitsgroep" - description: "Geef prioriteit aan content van deze groep in het rapport" - temperature: - label: "Temperatuur" - top_p: - label: "Top P" - llm_tool_triage: - fields: - model: - label: "Model" - llm_triage: - fields: - system_prompt: - label: "Systeemprompt" - description: "De prompt die wordt gebruikt voor triage. Antwoord met een enkel woord dat kan worden gebruikt om de actie te activeren" - max_post_tokens: - label: "Maximaal aantal plaatsingstokens" - description: "Het maximale aantal tokens om te scannen met LLM-triage" - stop_sequences: - label: "Stopsequenties" - description: "Instrueer het model om het genereren van tokens te stoppen als een van deze waarden wordt bereikt" - search_for_text: - label: "Tekst zoeken" - description: "Pas deze acties toe als de volgende tekst voorkomt in het LLM-antwoord" - category: - label: "Categorie" - description: "Toe te passen categorie op het topic" - tags: - label: "Tags" - description: "Toe te passen tags op het topic" - canned_reply: - label: "Antwoorden" - description: "Ruwe tekst van standaard antwoord te plaatsen in het topic" - canned_reply_user: - label: "Antwoordgebruiker" - description: "Gebruikersnaam van de gebruiker die het standaardantwoord plaatst" - hide_topic: - label: "Topic verbergen" - description: "Maak het topic onzichtbaar voor het publiek als dit wordt geactiveerd" - flag_type: - label: "Markeringstype" - description: "Type markering om toe te passen op het bericht (spam of gewoon verhogen voor beoordeling)" - flag_post: - label: "Bericht markeren" - description: "Markeert berichten (als spam of ter beoordeling)" - include_personal_messages: - label: "Persoonlijke berichten opnemen" - description: "Scan en triageer ook persoonlijke berichten" - model: - label: "Model" - description: "Taalmodel gebruikt voor triage" - temperature: - label: "Temperatuur" - discourse_ai: - title: "AI" - features: - back: "Terug" - disabled: "(uitgeschakeld)" - groups: "Groepen:" - no_persona: "Niet ingesteld" - no_groups: "Geen" - edit: "Bewerken" - expand_list: - one: "(Nog %{count})" - other: "(Nog %{count})" - collapse_list: "(minder weergeven)" - filters: - all: "Alles" - reset: "Resetten" - search: - name: "Zoeken" - embeddings: - name: "Insluitingen" - ai_helper: - name: "Helper" - proofread: Tekst proeflezen - explain: "Uitleggen" - smart_dates: "Slimme datums" - markdown_tables: "Markdowntabel genereren" - custom_prompt: "Aangepaste prompt" - spam: - name: "Spam" - description: "Identificeert potentiële spam met behulp van de geselecteerde LLM en markeert deze voor sitemoderators om te inspecteren in de beoordelingswachtrij" - modals: - select_option: "Selecteer een optie..." - spam: - short_title: "Spam" - title: "Spamafhandeling configureren" - select_llm: "Selecteer LLM" - custom_instructions: "Aangepaste instructies" - custom_instructions_help: "Aangepaste instructies specifiek voor jouw site om de AI te helpen bij het identificeren van spam, bijvoorbeeld 'Wees agressiever bij het scannen van berichten die niet in het Engels zijn'." - last_seven_days: "Afgelopen 7 dagen" - scanned_count: "Gescande berichten" - false_positives: "Onjuist gemarkeerd" - false_negatives: "Gemiste spam" - spam_detected: "Gedetecteerde spam" - custom_instructions_placeholder: "Sitespecifieke instructies voor de AI om spam nauwkeuriger te identificeren" - enable: "Inschakelen" - spam_tip: "AI-spamdetectie scant de eerste 3 berichten van alle nieuwe gebruikers bij openbare topics. De AI markeert ze voor beoordeling en blokkeert gebruikers als ze waarschijnlijk spam zijn." - settings_saved: "Instellingen opgeslagen" - spam_description: "Identificeert potentiële spam met behulp van de geselecteerde LLM en markeert deze voor sitemoderators om te inspecteren in de beoordelingswachtrij" - no_llms: "Geen LLM's beschikbaar" - test_button: "Testen..." - save_button: "Wijzigingen opslaan" - test_modal: - title: "Spamdetectie testen" - post_url_label: "Bericht-URL of -ID" - post_url_placeholder: "https://jouw-forum.com/t/topic/123/4 of bericht-ID" - result: "Resultaat" - scan_log: "Scanlog" - run: "Test uitvoeren" - spam: "Spam" - not_spam: "Geen spam" - stat_tooltips: - incorrectly_flagged: "Items die de AI-bot als spam heeft gemarkeerd en waar de moderators het niet mee eens waren" - missed_spam: "Door de community als spam gemarkeerde items die niet door de AI-bot zijn gedetecteerd en waar de moderators het mee eens waren" - errors: - scan_not_admin: - message: "Waarschuwing: het scannen van spam werkt niet goed omdat het account voor het scannen van spam geen beheerder is." - action: "Oplossen" - resolved: "De fout is opgelost!" - usage: - short_title: "Gebruik" - summary: "Samenvatting" - total_tokens: "Totaal aantal tokens" - tokens_over_time: "Tokens in de loop van de tijd" - features_breakdown: "Gebruik per functie" - feature: "Functie" - usage_count: "Gebruiksaantal" - model: "Model" - models_breakdown: "Gebruik per model" - users_breakdown: "Gebruik per gebruiker" - all_features: "Alle functies" - all_models: "Alle modellen" - username: "Gebruikersnaam" - total_requests: "Totaal aantal verzoeken" - request_tokens: "Verzoektokens" - response_tokens: "Antwoordtokens" - net_request_tokens: "Netto verzoektokens" - cached_tokens: "Gecachete tokens" - cached_request_tokens: "Gecachete verzoektokens" - no_users: "Geen gebruikergebruiksgegevens gevonden" - no_models: "Geen modelgebruiksgegevens gevonden" - no_features: "Geen functiegebruiksgegevens gevonden" - subheader_description: "Tokens zijn de basiseenheden die LLM's gebruiken om tekst te begrijpen en te genereren. Gebruiksgegevens kunnen de kosten beïnvloeden" - stat_tooltips: - total_requests: "Alle verzoeken aan LLM's via Discourse" - total_tokens: "Alle tokens die zijn gebruikt bij het prompten van een LLM" - request_tokens: "Tokens die zijn gebruikt wanneer de LLM probeert te begrijpen wat je zegt" - response_tokens: "Tokens die zijn gebruikt wanneer de LLM antwoordt op je prompt" - cached_tokens: "Eerder verwerkte verzoektokens die de LLM hergebruikt om prestaties en kosten te optimaliseren" - periods: - last_day: "Afgelopen 24 uur" - last_week: "Vorige week" - last_month: "Vorige maand" - custom: "Aangepast..." - ai_persona: - ai_tools: "Tools" - tool_strategies: - all: "Toepassen op alle antwoorden" - replies: - one: "Alleen toepassen op eerste antwoord" - other: "Alleen toepassen op eerste %{count} antwoorden" - back: "Vorige" - name: "Naam" - edit: "Bewerken" - export: "Exporteren" - description: "Beschrijving" - no_llm_selected: "Geen taalmodel geselecteerd" - max_context_posts: "Maximaal aantal contextberichten" - max_context_posts_help: "Het maximale aantal berichten dat als context voor de AI kan worden gebruikt bij het reageren op een gebruiker. (Leeg voor standaardinstelling)" - vision_enabled: Zicht ingeschakeld - vision_enabled_help: Indien ingeschakeld, zal de AI proberen afbeeldingen te begrijpen die gebruikers in het topic plaatsen, afhankelijk van het gebruikte model ter ondersteuning van zicht. Ondersteund door de nieuwste modellen van Anthropic, Google en OpenAI. - vision_max_pixels: Ondersteunde afbeeldingsgrootte - vision_max_pixel_sizes: - low: Lage kwaliteit - goedkoopst (256x256) - medium: Middelmatige kwaliteit (512x512) - high: Hoge kwaliteit - langzaamst (1024x1024) - tool_details: Tooldetails weergeven - tool_details_help: Toont eindgebruikers informatie over welke tools het taalmodel heeft geactiveerd. - mentionable: Vermeldingen toestaan - mentionable_help: Indien ingeschakeld, kunnen gebruikers in toegestane groepen deze gebruiker vermelden in berichten. De AI reageert als deze persona. - user: Gebruiker - create_user: Gebruiker maken - create_user_help: Je kunt optioneel een gebruiker aan deze persona koppelen. Als je dat doet, gebruikt de AI deze gebruiker om op verzoeken te antwoorden. - default_llm: Standaard taalmodel - default_llm_help: Het standaard taalmodel dat voor deze persona moet worden gebruikt. Vereist als je de persona wilt vermelden in openbare berichten. - question_consolidator_llm: Taalmodel voor Vragenconsolidator - question_consolidator_llm_help: Het te gebruiken taalmodel voor de vragenconsolidator. Je kunt een minder krachtig model kiezen om kosten te besparen. - system_prompt: Systeemprompt - forced_tool_strategy: Gedwongen toolstrategie - allow_chat_direct_messages: "Directe chatberichten toestaan" - allow_chat_direct_messages_help: "Indien ingeschakeld, kunnen gebruikers in toegestane groepen directe berichten naar deze persona sturen." - allow_chat_channel_mentions: "Vermelding in chatkanalen toestaan" - allow_chat_channel_mentions_help: "Indien ingeschakeld, kunnen gebruikers in toegestane groepen deze persona vermelden in chatkanalen." - allow_personal_messages: "Persoonlijke berichten toestaan" - allow_personal_messages_help: "Indien ingeschakeld, kunnen gebruikers in toegestane groepen persoonlijke berichten naar deze persona sturen." - allow_topic_mentions: "Vermelding in topics toestaan" - allow_topic_mentions_help: "Indien ingeschakeld, kunnen gebruikers in toegestane groepen deze persona vermelden in topics." - force_default_llm: "Altijd standaard taalmodel gebruiken" - save: "Opslaan" - saved: "Persona opgeslagen" - enabled: "Ingeschakeld?" - tools: "Ingeschakelde tools" - forced_tools: "Geforceerde tools" - allowed_groups: "Toegestane groepen" - confirm_delete: "Weet je zeker dat je deze persona wilt verwijderen?" - new: "Nieuwe persona" - no_personas: "Je hebt nog geen persona's gemaakt" - title: "Persona's" - short_title: "Persona's" - delete: "Verwijderen" - temperature: "Temperatuur" - temperature_help: "Te gebruiken temperatuur voor de LLM. Verhoog deze om de creativiteit te vergroten (laat dit leeg om de standaardinstelling van het model te gebruiken, doorgaans een waarde van 0,0 tot 2,0)" - top_p: "Top P" - top_p_help: "Te gebruiken Top P voor de LLM. Verhoog deze om de willekeurigheid te vergroten (laat dit leeg om de standaardinstelling van het model te gebruiken, doorgaans een waarde van 0,0 tot 2,0)" - priority: "Prioriteit" - priority_help: "Prioritaire persona's worden bovenaan de personalijst weergegeven voor gebruikers. Als meerdere persona's prioriteit hebben, worden deze alfabetisch gesorteerd." - tool_options: "Toolopties" - rag_conversation_chunks: "Conversatiechunks zoeken" - rag_conversation_chunks_help: "Het te gebruiken aantal chunks voor zoeken door het RAG-model. Verhoog dit om de hoeveelheid context te vergroten die de AI kan gebruiken." - persona_description: "Persona's zijn een krachtige functie waarmee je het gedrag van de AI-engine kunt aanpassen in je Discourse-forum. Ze fungeren als een 'systeembericht' dat de reacties en interacties van de AI stuurt, waardoor je een persoonlijkere en boeiendere gebruikerservaring creëert." - response_format: - open_modal: "Bewerken" - modal: - key_title: "Sleutel" - filters: - reset: "Resetten" - rag: - options: - rag_chunk_tokens: "Uploadchunktokens" - rag_chunk_tokens_help: "Het te gebruiken aantal tokens voor elke chunk in het RAG-model. Verhoog dit om de hoeveelheid context die de AI kan gebruiken te vergroten. (Als je dit wijzigt, worden alle uploads opnieuw geïndexeerd)" - rag_chunk_overlap_tokens: "Uploadchunkoverlaptokens" - rag_chunk_overlap_tokens_help: "Het te overlappen aantal tokens tussen chunks in het RAG-model. (Als je dit wijzigt, worden alle uploads opnieuw geïndexeerd)" - show_indexing_options: "Uploadopties weergeven" - hide_indexing_options: "Uploadopties verbergen" - uploads: - title: "Uploads" - button: "Bestanden toevoegen" - filter: "Uploads filteren" - indexed: "Geïndexeerd" - indexing: "Indexeren" - uploaded: "Klaar om te indexeren" - uploading: "Uploaden..." - remove: "Upload verwijderen" - tools: - back: "Vorige" - short_title: "Tools" - export: "Exporteren" - no_tools: "Je hebt nog geen tools gemaakt" - name: "Naam" - new: "Nieuwe tool" - description: "Beschrijving" - description_help: "Een duidelijke beschrijving van het doel van de tool voor het taalmodel" - subheader_description: "Tools breiden de mogelijkheden van AI-bots uit met door de gebruiker gedefinieerde JavaScript-functies." - summary: "Samenvatting" - summary_help: "Samenvatting van de tools die voor eindgebruikers moeten worden weergegeven" - script: "Script" - parameters: "Parameters" - save: "Opslaan" - remove_parameter: "Verwijderen" - parameter_required: "Vereist" - parameter_enum: "Enum" - parameter_name: "Parameternaam" - parameter_description: "Parameterbeschrijving" - enum_value: "Enumwaarde" - add_enum_value: "Enumwaarde toevoegen" - edit: "Bewerken" - test: "Test uitvoeren" - delete: "Verwijderen" - saved: "Tool opgeslagen" - confirm_delete: "Weet je zeker dat je deze tool wilt verwijderen?" - test_modal: - title: "AI-tool testen" - run: "Test uitvoeren" - result: "Testresultaat" - llms: - short_title: "LLM's" - no_llms: "Nog geen LLM's" - new: "Nieuw model" - display_name: "Naam" - name: "Model-ID" - provider: "Provider" - tokenizer: "Tokenizer" - url: "URL van de service die het model host" - api_key: "API-sleutel van de service die het model host" - enabled_chat_bot: "AI-botkiezer toestaan" - vision_enabled: "Zicht ingeschakeld" - ai_bot_user: "AI-botgebruiker" - save: "Opslaan" - edit: "Bewerken" - saved: "LLM-model opgeslagen" - back: "Vorige" - confirm_delete: Weet je zeker dat je dit model wilt verwijderen? - delete: Verwijderen - seeded_warning: "Dit model is vooraf geconfigureerd op je site en kan niet worden bewerkt." - quotas: - title: "Gebruiksquota's" - add_title: "Nieuw quotum maken" - group: "Groep" - max_tokens: "Maximaal aantal tokens" - max_usages: "Max. gebruiken" - duration: "Duur" - confirm_delete: "Weet je zeker dat je dit quotum wilt verwijderen?" - add: "Quotum toevoegen" - durations: - hour: "1 uur" - six_hours: "6 uur" - day: "24 uur" - week: "7 dagen" - custom: "Aangepast..." - hours: "uur" - max_tokens_help: "Maximum aantal tokens (woorden en tekens) dat elke gebruiker in deze groep kan gebruiken binnen de opgegeven tijdsduur. Tokens zijn de eenheden die door AI-modellen worden gebruikt om tekst te verwerken. Ruwweg 1 token = 4 tekens of 3/4 van een woord." - max_usages_help: "Maximum aantal keren dat elke gebruiker in deze groep het AI-model kan gebruiken binnen de opgegeven tijdsduur. Dit quotum wordt bijgehouden per individuele gebruiker, niet gedeeld door de groep." - usage: - ai_bot: "AI-bot" - ai_helper: "Helper" - ai_persona: "Persona (%{persona})" - ai_summarization: "Samenvatten" - ai_embeddings_semantic_search: "AI zoeken" - ai_spam: "Spam" - in_use_warning: - one: "Dit model wordt momenteel gebruikt door %{settings}. Als het onjuist is geconfigureerd, werkt de functie niet zoals verwacht." - other: "Dit model wordt momenteel gebruikt door de volgende: %{settings}. Als het onjuist is geconfigureerd, werken functies niet zoals verwacht. " - model_description: - none: "Algemene instellingen die werken voor de meeste taalmodellen" - anthropic-claude-opus-4-0: "Het intelligentste model van Antropic" - anthropic-claude-3-5-haiku-latest: "Snel en kosteneffectief" - google-gemini-2-5-flash: "Lichtgewicht, snel en kostenefficiënt met multimodale redenering" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Efficiënt lichtgewicht meertalig model" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Krachtig multifunctioneel model" - mistral-mistral-large-latest: "Het krachtigste model van Mistral" - mistral-pixtral-large-latest: "Het krachtigste model met zichtcapaciteit van Mistral" - preseeded_model_description: "Voorgeconfigureerd open-source model met %{model}" - configured: - title: "Geconfigureerde LLM's" - preconfigured_llms: "Selecteer je LLM" - preconfigured: - title_no_llms: "Selecteer een sjabloon om te beginnen" - title: "Niet-geconfigureerde LLM-sjablonen" - description: "LLM's (Large Language Models) zijn AI-tools die zijn geoptimaliseerd voor taken als het samenvatten van inhoud, het genereren van rapporten, het automatiseren van klantinteracties en het faciliteren van forummoderatie en inzichten" - fake: "Handmatige configuratie" - button: "Instellen" - next: - title: "Volgende" - tests: - title: "Test uitvoeren" - running: "Test uitvoeren..." - success: "Succes!" - failure: "Een poging om verbinding te maken met het model resulteerde in deze fout: %{error}" - hints: - name: "We nemen dit op in de API-aanroep om aan te geven welk model we gebruiken" - vision_enabled: "Indien ingeschakeld, zal de AI proberen afbeeldingen te begrijpen. Dit is afhankelijk van het gebruikte model ter ondersteuning van zicht. Ondersteund door de nieuwste modellen van Anthropic, Google en OpenAI." - enabled_chat_bot: "Indien ingeschakeld, kunnen gebruikers dit model selecteren bij het maken van PB's met de AI-bot." - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Aangepast" - provider_fields: - access_key_id: "Toegangssleutel-ID AWS Bedrock" - region: "Regio AWS Bedrock" - organization: "Optionele organisatie-ID OpenAI" - disable_system_prompt: "Systeembericht uitschakelen in prompts" - enable_native_tool: "Ondersteuning voor native tools inschakelen" - disable_native_tools: "Ondersteuning voor native tool uitschakelen (gebruik tools op XML-basis)" - provider_order: "Volgorde van providers (door komma's gescheiden lijst)" - provider_quantizations: "Volgorde van providerkwantificeringen (door komma's gescheiden lijst, bijvoorbeeld: fp16, fp8)" - disable_streaming: "Streamingvoltooiingen uitschakelen (streaming- naar niet-streaming-verzoeken converteren)" - related_topics: - title: "Gerelateerde topics" - pill: "Gerelateerd" - ai_helper: - title: "Stel wijzigingen voor met behulp van AI" - description: "Kies een van de onderstaande opties, dan zal de AI je een nieuwe versie van de tekst voorstellen." - selection_hint: "Tip: je kunt ook een deel van de tekst selecteren voordat je de hulp opent om alleen dat deel te herschrijven." - suggest: "Voorstellen met AI" - suggest_errors: - too_many_tags: - one: "Je kunt maximaal %{count} tag hebben" - other: "Je kunt maximaal %{count} tags hebben" - no_suggestions: "Geen suggesties beschikbaar" - missing_content: "Voer wat inhoud in om suggesties te genereren." - context_menu: - trigger: "AI vragen" - loading: "AI is aan het genereren" - cancel: "Annuleren" - confirm: "Bevestigen" - discard: "Weggooien" - changes: "Voorgestelde bewerkingen" - custom_prompt: - title: "Aangepaste prompt" - placeholder: "Voer een aangepaste prompt in..." - submit: "Prompt verzenden" - translate_prompt: "Vertalen naar %{language}" - post_options_menu: - trigger: "AI vragen" - title: "AI vragen" - loading: "AI is aan het genereren" - close: "Sluiten" - copy: "Kopiëren" - copied: "Gekopieerd!" - cancel: "Annuleren" - insert_footnote: "Voetnoot toevoegen" - footnote_disabled: "Automatisch invoegen uitgeschakeld, klik op de knop Kopiëren en bewerk het handmatig" - footnote_credits: "Uitleg door AI" - fast_edit: - suggest_button: "Bewerking voorstellen" - thumbnail_suggestions: - title: "Voorgestelde miniaturen" - select: "Selecteren" - selected: "Geselecteerd" - image_caption: - button_label: "Bijschrift met AI" - generating: "Bijschrift genereren..." - credits: "Bijschrift door AI" - save_caption: "Opslaan" - automatic_caption_setting: "Automatische bijschriften inschakelen" - automatic_caption_loading: "Afbeeldingsbijschriften genereren..." - automatic_caption_dialog: - prompt: "Dit bericht bevat afbeeldingen zonder bijschrift. Wil je automatische bijschriften inschakelen bij het uploaden van afbeeldingen? (Je kunt dit later wijzigen in je voorkeuren)" - confirm: "Inschakelen" - cancel: "Niet meer vragen" - no_content_error: "Voeg eerst inhoud toe om er AI-acties op uit te voeren" - reviewables: - model_used: "Gebruikt model:" - accuracy: "Nauwkeurigheid:" - embeddings: - short_title: "Insluitingen" - new: "Nieuwe insluiting" - back: "Terug" - save: "Opslaan" - saved: "Insluitingsconfiguratie opgeslagen" - delete: "Verwijderen" - confirm_delete: Weet je zeker dat je deze insluitingsconfiguratie wilt verwijderen? - empty: "Je hebt nog geen insluitingen ingesteld" - presets: "Selecteer een preset..." - configure_manually: "Handmatig configureren" - edit: "Bewerken" - seeded_warning: "Dit is vooraf geconfigureerd op je site en kan niet worden bewerkt." - tests: - title: "Test uitvoeren" - running: "Test uitvoeren..." - success: "Succes!" - failure: "Pogingen om een insluitingen te genereren resulteerden in: %{error}" - hints: - dimensions_warning: "Eenmaal opgeslagen kan deze waarde niet meer worden gewijzigd." - matryoshka_dimensions: "Bepaalt de grootte van geneste insluitingen die worden gebruikt voor hiërarchische of meerlaagse weergave van gegevens, vergelijkbaar met hoe geneste poppetjes in elkaar passen." - sequence_length: "Het maximale aantal tokens dat in één keer kan worden verwerkt bij het maken van insluitingen of het verwerken van een query." - distance_function: "Bepaalt hoe de mate van overeenkomst tussen insluitingen wordt berekend, met behulp van cosinusafstand (meting van de hoek tussen vectoren) of negatief binnenproduct (meting van de overlap van vectorwaarden)." - display_name: "Naam" - provider: "Provider" - url: "URL insluitingsservice" - api_key: "API-sleutel insluitingsservice" - tokenizer: "Tokenizer" - dimensions: "Insluitingsafmetingen" - max_sequence_length: "Sequentielengte" - embed_prompt: "Insluitingsprompt" - search_prompt: "Zoekprompt" - matryoshka_dimensions: "Matroesjka-afmetingen" - distance_function: "Afstandsfunctie" - distance_functions: - "<#>": "Negatief binnenproduct" - <=>: "Cosinusafstand" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Aangepast" - provider_fields: - model_name: "Modelnaam" - semantic_search: "Topics (semantisch)" - semantic_search_loading: "Zoeken naar meer resultaten met behulp van AI" - semantic_search_results: - toggle: "%{count} resultaten gevonden met AI worden weergegeven" - toggle_hidden: "%{count} resultaten gevonden met AI zijn verborgen" - none: "Sorry, AI zoeken heeft geen overeenkomende topics gevonden" - new: "Druk op 'Zoeken' om nieuwe resultaten te zoeken met AI" - unavailable: "AI-resultaten niet beschikbaar" - semantic_search_tooltips: - results_explanation: "Als dit is ingeschakeld, worden hieronder extra AI-zoekresultaten toegevoegd." - invalid_sort: "Zoekresultaten moeten worden gesorteerd op Relevantie om AI-resultaten weer te geven" - semantic_search_unavailable_tooltip: "Zoekresultaten moeten worden gesorteerd op Relevantie om AI-resultaten weer te geven" - ai_generated_result: "Zoekresultaat gevonden met AI" - quick_search: - suffix: "in alle topics en berichten met AI" - ai_artifact: - expand_view_label: "Weergave uitvouwen" - collapse_view_label: "Volledig scherm verlaten (ESC of knop Terug)" - click_to_run_label: "Artefact uitvoeren" - ai_bot: - llm: "Model" - pm_warning: "AI-chatbotberichten worden regelmatig gecontroleerd door moderators." - cancel_streaming: "Stoppen met antwoorden" - default_pm_prefix: "[Ongetitelde PB van AI-bot]" - shortcut_title: "Start een PB met een AI-bot" - share: "AI-conversatie kopiëren" - conversation_shared: "Conversatie gekopieerd" - debug_ai: "Ruw AI-verzoek en -antwoord weergeven" - debug_ai_modal: - title: "AI-interactie weergeven" - copy_request: "Verzoek kopiëren" - copy_response: "Antwoord kopiëren" - request_tokens: "Verzoektokens:" - response_tokens: "Antwoordtokens:" - request: "Verzoek" - response: "Antwoord" - next_log: "Volgende" - previous_log: "Vorige" - share_full_topic_modal: - title: "Conversatie openbaar delen" - share: "Link delen en kopiëren" - update: "Link bijwerken en kopiëren" - delete: "Deling verwijderen" - share_ai_conversation: - name: "AI-conversatie delen" - title: "Deel deze AI-conversatie openbaar" - invite_ai_conversation: - button: "Uitnodigen" - ai_label: "AI" - ai_title: "Conversatie met AI" - share_modal: - title: "AI-conversatie kopiëren" - copy: "Kopiëren" - context: "Te delen interacties:" - share_tip: "Je kunt ook de hele conversatie delen" - bot_names: - fake: "Neppe testbot" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Vandaag" - last_7_days: "Afgelopen 7 dagen" - last_30_days: "Afgelopen 30 dagen" - sentiments: - dashboard: - title: "Sentiment" - sentiment_analysis: - filter_types: - all: "Alle" - positive: "Positief" - neutral: "Neutraal" - negative: "Negatief" - group_types: - category: "Categorie" - tag: "Taggen" - table: - sentiment: "Sentiment" - total_count: "Totaal" - summarization: - chat: - title: "Berichten samenvatten" - description: "Selecteer hieronder een optie om het gesprek samen te vatten dat tijdens de gewenste periode is gevoerd." - summarize: "Samenvatten" - since: - one: "Afgelopen uur" - other: "Afgelopen %{count} uur" - topic: - title: "Topicsamenvatting" - close: "Samenvattingspaneel sluiten" - topic_list_layout: - button: - compact: "Compact" - expanded: "Uitgebreid" - expanded_description: "met AI-samenvattingen" - discobot_discoveries: - regular_results: "Topics" - collapse: "Samenvouwen" - tooltip: - actions: - disable: "Uitschakelen" - review: - types: - reviewable_ai_post: - title: "Door AI gemarkeerd bericht" - reviewable_ai_chat_message: - title: "Door AI gemarkeerd chatbericht" diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml deleted file mode 100644 index 59c64be4..00000000 --- a/config/locales/client.pl_PL.yml +++ /dev/null @@ -1,617 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -pl_PL: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Umożliwia wyszukiwanie AI" - stream_completion: "Umożliwia strumieniowe uzupełnianie osobowości AI" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "Emocje" - reports: - filters: - group_by: - label: "Grupuj według" - sort_by: - label: "Sortuj po" - tag: - label: "Tag" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Nadawca" - description: "Użytkownik, który wyśle raport" - receivers: - label: "Odbiorcy" - description: "Użytkownicy, którzy otrzymają raport (e-maile zostaną wysłane bezpośrednio, nazwy użytkowników zostaną wysłane w wiadomości prywatnej)" - topic_id: - label: "ID tematu" - title: - label: "Tytuł" - description: "Tytuł raportu" - days: - label: "Dni" - description: "Zakres czasowy raportu" - offset: - label: "Przesunięcie" - description: "Podczas testowania możesz chcieć uruchomić raport historycznie, użyj przesunięcia, aby rozpocząć raport we wcześniejszej dacie" - instructions: - label: "Instrukcje" - description: "Instrukcje dostarczone do dużego modelu językowego" - sample_size: - label: "Wielkość próbki" - description: "Liczba postów do pobrania na potrzeby raportu" - tokens_per_post: - label: "Tokeny za post" - description: "Liczba tokenów LLM do użycia na post" - model: - label: "Model" - description: "LLM do wykorzystania do generowania raportów" - categories: - label: "Kategorie" - description: "Filtruj tematy tylko do tych kategorii" - tags: - label: "Tagi" - description: "Filtruj tematy tylko do tych tagów" - exclude_tags: - label: "Wyklucz tagi" - description: "Wyklucz tematy z tymi tagami" - exclude_categories: - label: "Wyklucz kategorie" - description: "Wyklucz tematy z tych kategorii" - allow_secure_categories: - label: "Zezwalaj na bezpieczne kategorie" - description: "Zezwalaj na generowanie raportu dla tematów w bezpiecznych kategoriach" - suppress_notifications: - label: "Wyłącz powiadomienia" - description: "Pomiń powiadomienia, które raport może generować, przekształcając je w treść. Spowoduje to ponowne przypisanie wzmianek i linków wewnętrznych." - debug_mode: - label: "Tryb debugowania" - description: "Włącz tryb debugowania, aby zobaczyć nieprzetworzone dane wejściowe i wyjściowe LLM." - priority_group: - label: "Grupa priorytetowa" - description: "Nadaj priorytet treściom z tej grupy w raporcie" - temperature: - label: "Temperatura" - description: "Temperatura używana dla LLM. Zwiększ, aby zwiększyć losowość (pozostaw puste, aby użyć domyślnego modelu)." - llm_tool_triage: - fields: - model: - label: "Model" - description: "Domyślny model językowy używany do selekcji" - tool: - label: "Narzędzie" - llm_persona_triage: - fields: - persona: - label: "Osobowość" - silent_mode: - label: "Tryb cichy" - description: "W trybie cichym persona otrzyma zawartość, ale nie opublikuje niczego na forum - przydatne podczas przeprowadzania selekcji przy użyciu narzędzi." - llm_triage: - fields: - system_prompt: - label: "Monit systemowy" - description: "Wskazówka, która zostanie użyta do trializacji, upewnij się, że odpowiedziałaś jednym słowem, którego możesz użyć do uruchomienia akcji" - max_post_tokens: - label: "Maksymalna ilość tokenów postu" - description: "Maksymalna liczba tokenów do skanowania przy użyciu selekcji LLM" - stop_sequences: - description: "Poinstruuj model, aby zatrzymał generowanie tokenów po osiągnięciu jednej z tych wartości" - search_for_text: - label: "Wyszukaj tekst" - description: "Jeśli w odpowiedzi LLM pojawi się następujący tekst, wykonaj następujące czynności" - category: - label: "Kategoria" - description: "Kategoria do zastosowania w temacie" - tags: - label: "Tagi" - description: "Tagi do zastosowania w temacie" - canned_reply: - label: "Odpowiedz" - description: "Nieprzetworzony tekst odpowiedzi na post w temacie" - canned_reply_user: - label: "Odpowiedz użytkownikowi" - description: "Nazwa użytkownika, który ma opublikować odpowiedź w szablonie" - hide_topic: - label: "Ukryj temat" - description: "Spraw, by temat nie był widoczny publicznie, jeśli zostanie uruchomiony." - flag_type: - label: "Typ flagi" - description: "Rodzaj flagi, która ma zostać zastosowana do postu (spam lub wysłanie do sprawdzenia)." - flag_post: - label: "Oflaguj post" - description: "Oflaguj post (jako spam lub do sprawdzenia)" - include_personal_messages: - label: "Dołącz osobiste wiadomości" - description: "Skanuj i sortuj również wiadomości prywatne" - reply_persona: - label: "Persona odpowiedzi" - description: "AI Persona użyta do odpowiedzi (musi mieć domyślną wartość LLM), będzie traktowana priorytetowo nad gotową odpowiedzią" - model: - label: "Model" - description: "Model językowy używany do selekcji" - temperature: - label: "Temperatura" - description: "Temperatura używana dla LLM. Zwiększ, aby zwiększyć losowość (pozostaw puste, aby użyć domyślnego modelu)." - discourse_ai: - title: "AI" - features: - back: "Poprzednia" - disabled: "(wyłączone)" - persona: - one: "Osobowość:" - few: "Persony:" - many: "Persony:" - other: "Persony:" - groups: "Grupy:" - no_persona: "Nie ustawiony" - no_groups: "Brak" - edit: "Edytuj" - expand_list: - one: "(%{count} więcej)" - few: "(%{count} więcej)" - many: "(%{count} więcej)" - other: "(%{count} więcej)" - collapse_list: "(pokaż mniej)" - filters: - all: "Wszystkie" - reset: "Przywróć" - search: - name: "Szukaj" - ai_helper: - name: "Pomocnik" - proofread: Popraw tekst - explain: "Wyjaśnij" - markdown_tables: "Wygeneruj tabelę Markdown" - custom_prompt: "Niestandardowy prompt" - spam: - name: "Spam" - description: "Identyfikuje potencjalny spam przy użyciu wybranego LLM i oznacza go dla moderatorów witryny w celu sprawdzenia w kolejce przeglądania." - modals: - select_option: "Wybierz opcję..." - spam: - short_title: "Spam" - title: "Skonfiguruj obsługę spamu" - select_llm: "Wybierz LLM" - custom_instructions: "Instrukcje niestandardowe" - custom_instructions_help: "Niestandardowe instrukcje specyficzne dla Twojej witryny, aby pomóc sztucznej inteligencji w identyfikacji spamu, np. \"Bądź bardziej agresywny w skanowaniu postów nie w języku angielskim\"." - last_seven_days: "Ostatnie 7 dni" - scanned_count: "Przeskanowane posty" - false_positives: "Nieprawidłowo oznaczony" - false_negatives: "Pominięty spam" - spam_detected: "Wykryto spam" - custom_instructions_placeholder: "Instrukcje dla AI specyficzne dla witryny, aby pomóc w dokładniejszej identyfikacji spamu" - enable: "Włącz" - spam_tip: "Wykrywanie spamu przez sztuczną inteligencję będzie skanować pierwsze 3 posty wszystkich nowych użytkowników w tematach publicznych. Oznaczy je do sprawdzenia i zablokuje użytkowników, jeśli istnieje prawdopodobieństwo, że są spamem." - settings_saved: "Ustawienia zapisane" - spam_description: "Identyfikuje potencjalny spam przy użyciu wybranego LLM i oznacza go dla moderatorów witryny w celu sprawdzenia w kolejce przeglądania." - no_llms: "Brak dostępnych LLM" - test_button: "Test..." - save_button: "Zapisz zmiany" - test_modal: - title: "Test wykrywania spamu" - post_url_label: "Adres URL lub identyfikator posta" - result: "Wynik" - scan_log: "Dziennik skanowania" - run: "Przeprowadź test" - spam: "Spam" - not_spam: "Nie spam" - errors: - scan_not_admin: - action: "Napraw" - resolved: "Błąd został rozwiązany!" - usage: - short_title: "Użycie" - summary: "Podsumowanie" - total_tokens: "Wszystkie tokeny" - tokens_over_time: "Tokeny w czasie" - features_breakdown: "Wykorzystanie według funkcji" - feature: "Funkcja" - usage_count: "Liczba użyć" - model: "Model" - models_breakdown: "Wykorzystanie na model" - users_breakdown: "Wykorzystanie na użytkownika" - all_features: "Wszystkie funkcje" - all_models: "Wszystkie modele" - username: "Nazwa użytkownika" - total_requests: "Razem zapytań" - request_tokens: "Tokeny żądania" - response_tokens: "Tokeny odpowiedzi" - net_request_tokens: "Tokeny żądania sieci" - cached_tokens: "Tokeny buforowane" - cached_request_tokens: "Buforowane tokeny żądań" - no_users: "Nie znaleziono danych użycia użytkownika" - no_models: "Nie znaleziono danych o użyciu modelu" - no_features: "Nie znaleziono danych o użyciu funkcji" - subheader_description: "Tokeny są podstawowymi jednostkami, których LLM używa do rozumienia i generowania tekstu, dane dotyczące użytkowania mogą wpływać na koszty" - stat_tooltips: - total_requests: "Wszystkie prośby kierowane do LLM za pośrednictwem Discourse" - total_tokens: "Wszystkie tokeny używane podczas monitowania LLM" - request_tokens: "Tokeny używane, gdy LLM próbuje zrozumieć, co mówisz" - response_tokens: "Tokeny używane, gdy LLM odpowiada na twój prompt" - cached_tokens: "Wcześniej przetworzone tokeny żądań, które LLM ponownie wykorzystuje w celu optymalizacji wydajności i kosztów." - periods: - last_day: "Ostatnie 24 godziny" - last_week: "Ostatni tydzień" - last_month: "Ostatni miesiąc" - custom: "Niestandardowy..." - ai_persona: - ai_tools: "Narzędzia" - tool_strategies: - all: "Zastosuj do wszystkich odpowiedzi" - back: "Poprzednia" - name: "Nazwa" - edit: "Edytuj" - export: "Eksport" - description: "Opis" - no_llm_selected: "Nie wybrano modelu językowego" - use_parent_llm: "Użyj modelu językowego person" - max_context_posts_help: "Maksymalna liczba postów do wykorzystania jako kontekst dla sztucznej inteligencji podczas odpowiadania użytkownikowi. (domyślnie puste)" - vision_enabled: Wizja włączona - vision_enabled_help: Jeśli jest włączona, sztuczna inteligencja będzie próbowała zrozumieć obrazy publikowane przez użytkowników w temacie, w zależności od używanego modelu wspierającego widzenie. Obsługiwane przez najnowsze modele Anthropic, Google i OpenAI. - vision_max_pixels: Obsługiwany rozmiar obrazu - vision_max_pixel_sizes: - low: Niska jakość - najtańsza (256x256) - medium: Średnia jakość (512x512) - high: Wysoka jakość - najwolniejsza (1024x1024) - tool_details: Pokaż szczegóły narzędzia - tool_details_help: Pokaże użytkownikom końcowym szczegółowe informacje na temat narzędzi uruchomionych przez model językowy. - mentionable: Zezwalaj na wzmianki - user: Użytkownik - create_user: Utwórz użytkownika - create_user_help: Opcjonalnie możesz przypisać użytkownika do tej osoby. Jeśli to zrobisz, sztuczna inteligencja użyje tego użytkownika do odpowiedzi na żądania. - default_llm: Domyślny model językowy - default_llm_help: Domyślny model językowy używany dla tej persony. Wymagane, jeśli chcesz wspomnieć o osobie w postach publicznych. - system_prompt: Prompt systemowy - allow_personal_messages: "Zezwalaj na wiadomości osobiste" - allow_personal_messages_help: "Jeśli ta opcja jest włączona, użytkownicy w dozwolonych grupach mogą wysyłać osobiste wiadomości do tej persony." - allow_topic_mentions: "Zezwalaj na wzmianki o temacie" - allow_topic_mentions_help: "Jeśli ta opcja jest włączona, użytkownicy w dozwolonych grupach mogą wspominać o tej personie w tematach." - force_default_llm: "Zawsze używaj domyślnego modelu języka" - save: "Zapisz" - saved: "Persona zapisana" - enabled: "Włączona?" - tools: "Włączone narzędzia" - forced_tools: "Narzędzia wymuszone" - allowed_groups: "Dozwolone grupy" - confirm_delete: "Czy na pewno chcesz usunąć tę personę?" - new: "Nowa persona" - no_personas: "Nie utworzyłeś jeszcze żadnych person" - title: "Persony" - short_title: "Persony" - delete: "Usuń" - temperature: "Temperatura" - temperature_help: "Temperatura używana w LLM. Zwiększ, aby zwiększyć kreatywność (pozostaw puste, aby użyć domyślnej wartości modelu, zazwyczaj od 0,0 do 2,0)." - priority: "Priorytet" - priority_help: "Priorytetowe persony są wyświetlane użytkownikom na górze listy person. Jeśli wiele person ma priorytet, zostaną one posortowane alfabetycznie." - tool_options: "Opcje narzędzi" - rag_conversation_chunks: "Przeszukuj fragmenty konwersacji" - response_format: - open_modal: "Edytuj" - modal: - key_title: "Klucz" - list: - enabled: "Bot AI?" - ai_bot: - title: "Opcje bota AI" - filters: - reset: "Przywróć" - rag: - options: - show_indexing_options: "Pokaż opcje przesyłania" - hide_indexing_options: "Ukryj opcje przesyłania" - uploads: - title: "Pliki" - button: "Dodaj pliki" - filter: "Filtruj przesyłane pliki" - indexed: "Zindeksowano" - indexing: "Indeksowanie" - uploaded: "Gotowe do indeksowania" - uploading: "Przesyłanie..." - remove: "Usuń przesyłanie" - tools: - back: "Poprzednia" - short_title: "Narzędzia" - export: "Eksport" - no_tools: "Nie utworzyłeś jeszcze żadnych narzędzi" - name: "Nazwa" - name_help: "Nazwa pojawi się w interfejsie użytkownika Discourse i będzie krótkim identyfikatorem, którego będziesz używać do znajdowania narzędzia w różnych ustawieniach. Powinna być ona unikatowa (jest wymagana)" - new: "Nowe narzędzie" - tool_name: "Nazwa narzędzia" - tool_name_help: "Nazwa narzędzia jest prezentowana w dużym modelu językowym. Nie jest ona odrębna, ale jest odrębna dla każdej persony. (persona sprawdza poprawność przy zapisywaniu)" - description: "Opis" - description_help: "Jasny opis celu narzędzia dla modelu językowego" - subheader_description: "Narzędzia rozszerzają możliwości botów AI o zdefiniowane przez użytkownika funkcje JavaScript." - summary: "Podsumowanie" - summary_help: "Podsumowanie celu narzędzi wyświetlane użytkownikom końcowym" - script: "Skrypt" - parameters: "Parametry" - save: "Zapisz" - remove_parameter: "Usuń" - parameter_required: "Wymagane" - parameter_enum: "Enum" - parameter_name: "Nazwa parametru" - parameter_description: "Opis parametru" - enum_value: "Wartość enum" - add_enum_value: "Dodaj wartość enum" - edit: "Edytuj" - test: "Przeprowadź test" - delete: "Usuń" - saved: "Narzędzie zapisane" - confirm_delete: "Czy na pewno chcesz usunąć to narzędzie?" - test_modal: - title: "Przetestuj narzędzie AI" - run: "Przeprowadź test" - result: "Wynik testu" - llms: - short_title: "LLMs" - no_llms: "Nie ma jeszcze LLM" - new: "Nowy model" - display_name: "Nazwa" - name: "Identyfikator modelu" - provider: "Dostawca" - tokenizer: "Tokenizer" - url: "Adres URL usługi hostującej model" - api_key: "Klucz API usługi hostującej model" - enabled_chat_bot: "Zezwól na wybór bota AI" - vision_enabled: "Wizja włączona" - ai_bot_user: "Użytkownik bota AI" - save: "Zapisz" - edit: "Edytuj" - saved: "Model LLM zapisany" - back: "Poprzednia" - confirm_delete: Czy na pewno chcesz usunąć ten model? - delete: Usuń - seeded_warning: "Ten model jest wstępnie skonfigurowany w Twojej witrynie i nie można go edytować." - quotas: - title: "Limity wykorzystania" - add_title: "Utwórz nowy limit" - group: "Grupa" - max_tokens: "Maksymalna liczba tokenów" - max_usages: "Maksymalna liczba użyć" - duration: "Czas trwania" - confirm_delete: "Czy na pewno chcesz usunąć ten limit?" - add: "Dodaj limit" - durations: - hour: "1 godzina" - six_hours: "6 godzin" - day: "24 godziny" - week: "7 dni" - custom: "Niestandardowy..." - hours: "godzin" - max_tokens_help: "Maksymalna liczba tokenów (słów i znaków), które każdy użytkownik w tej grupie może wykorzystać w określonym czasie. Tokeny to jednostki używane przez modele AI do przetwarzania tekstu - w przybliżeniu 1 token = 4 znaki lub 3/4 słowa." - max_tokens_required: "Musi być ustawiony, jeśli nie ustawiono maksymalnego użycia" - max_usages_help: "Maksymalna liczba przypadków, w których każdy użytkownik w tej grupie może użyć modelu AI w określonym czasie. Ten limit jest śledzony dla poszczególnych użytkowników, a nie współdzielony przez grupę." - max_usages_required: "Musi być ustawiony, jeśli nie ustawiono maksymalnej liczby tokenów" - usage: - ai_bot: "Bot AI" - ai_helper: "Pomocnik" - ai_persona: "Persona (%{persona})" - ai_summarization: "Podsumuj" - ai_embeddings_semantic_search: "Wyszukiwanie AI" - ai_spam: "Spam" - model_description: - none: "Ogólne ustawienia, które działają dla większości modeli językowych" - anthropic-claude-opus-4-0: "Najbardziej inteligentny model Anthropic" - anthropic-claude-3-5-haiku-latest: "Szybko i ekonomicznie" - google-gemini-2-5-flash: "Lekki, szybki i ekonomiczny z multimodalnym rozumowaniem" - open_ai-o3: "Najbardziej wydajny model rozumowania Open AI" - open_ai-o4-mini: "Zaawansowany, efektywny kosztowo model rozumowania" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Wydajny, lekki model wielojęzyczny" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Wydajny model wielofunkcyjny" - mistral-mistral-large-latest: "Najpotężniejszy model Mistral" - mistral-pixtral-large-latest: "Najpotężniejszy model Mistral zdolny do widzenia" - preseeded_model_description: "Wstępnie skonfigurowany model open-source wykorzystujący %{model}" - configured: - title: "Skonfigurowane LLM" - preconfigured_llms: "Wybierz swój LLM" - preconfigured: - title_no_llms: "Wybierz szablon, aby rozpocząć" - title: "Nieskonfigurowane szablony LLM" - description: "LLM (Large Language Models) to narzędzia sztucznej inteligencji zoptymalizowane do zadań takich jak podsumowywanie treści, generowanie raportów, automatyzacja interakcji z klientami oraz ułatwianie moderowania i analizowania forów." - fake: "Konfiguracja ręczna" - button: "Skonfiguruj" - next: - title: "Następna" - tests: - title: "Przeprowadź test" - running: "Przeprowadzam test..." - success: "Sukces!" - failure: "Próba nawiązania kontaktu z modelem zwróciła następujący błąd: %{error}" - hints: - display_name: "Nazwa używana do odwoływania się do tego modelu w interfejsie Twojej witryny." - name: "Uwzględniamy to w wywołaniu API, aby określić, którego modelu będziemy używać" - enabled_chat_bot: "Jeśli ta opcja jest włączona, użytkownicy mogą wybrać ten model podczas tworzenia wiadomości prywatnych za pomocą bota AI" - providers: - google: "Google" - fake: "Niestandardowe" - provider_fields: - organization: "Opcjonalny identyfikator organizacji OpenAI" - disable_system_prompt: "Wyłącz wiadomość systemową w promptach" - enable_native_tool: "Włącz obsługę natywnych narzędzi" - provider_order: "Kolejność dostawców (lista rozdzielona przecinkami)" - reasoning_tokens: "Liczba tokenów użytych do wnioskowania" - disable_temperature: "Wyłącz temperaturę (niektóre modele nie obsługują temperatury)" - related_topics: - title: "Powiązane tematy" - pill: "Powiązane" - ai_helper: - title: "Zaproponuj zmiany za pomocą AI" - description: "Wybierz jedną z poniższych opcji, a sztuczna inteligencja zasugeruje Ci nową wersję tekstu." - selection_hint: "Wskazówka: Możesz także zaznaczyć część tekstu przed otwarciem pomocnika, aby przepisać tylko ten fragment." - suggest: "Zasugeruj za pomocą AI" - suggest_errors: - no_suggestions: "Brak dostępnych sugestii" - missing_content: "Wprowadź treść, aby wygenerować sugestie." - context_menu: - trigger: "Zapytaj AI" - loading: "AI generuje" - cancel: "Anuluj" - confirm: "Potwierdź" - discard: "Odrzuć" - changes: "Sugerowane zmiany" - custom_prompt: - title: "Niestandardowy prompt" - placeholder: "Wprowadź niestandardowy monit..." - submit: "Wyślij prompt" - translate_prompt: "Przetłumacz na %{language}" - post_options_menu: - trigger: "Zapytaj AI" - title: "Zapytaj AI" - loading: "AI generuje" - close: "Zamknij" - copy: "Kopiuj" - copied: "Skopiowane!" - cancel: "Anuluj" - insert_footnote: "Dodaj przypis" - footnote_disabled: "Automatyczne wstawianie wyłączone, kliknij przycisk kopiowania i edytuj ręcznie." - footnote_credits: "Wyjaśnienie przez AI" - fast_edit: - suggest_button: "Zaproponuj edycję" - thumbnail_suggestions: - title: "Sugerowane miniatury" - select: "Wybierz" - selected: "Wybrany" - image_caption: - save_caption: "Zapisz" - automatic_caption_dialog: - prompt: "Ten post zawiera obrazy bez podpisów. Czy chcesz włączyć automatyczne podpisy przy przesyłaniu zdjęć? (Można to później zmienić w preferencjach)." - confirm: "Włącz" - cancel: "Nie pytaj ponownie" - no_content_error: "Najpierw dodaj zawartość, aby wykonać na niej działania AI." - reviewables: - model_used: "Zastosowany model:" - accuracy: "Dokładność:" - embeddings: - back: "Poprzednia" - save: "Zapisz" - delete: "Usuń" - presets: "Wybierz ustawienie wstępne..." - configure_manually: "Skonfiguruj ręcznie" - edit: "Edytuj" - seeded_warning: "To jest wstępnie skonfigurowane na twojej stronie i nie może być edytowane." - tests: - title: "Przeprowadź test" - running: "Przeprowadzam test..." - success: "Sukces!" - hints: - dimensions_warning: "Po zapisaniu wartość ta nie może zostać zmieniona." - display_name: "Nazwa" - providers: - google: "Google" - fake: "Niestandardowe" - provider_fields: - model_name: "Nazwa modelu" - semantic_search: "Tematy (semantyczne)" - semantic_search_loading: "Wyszukiwanie większej liczby wyników przy użyciu AI" - semantic_search_results: - toggle: "Wyświetlanie %{count} wyników znalezionych przy użyciu AI" - toggle_hidden: "Ukrywanie %{count} wyników znalezionych przy użyciu AI" - none: "Przepraszamy, nasze wyszukiwanie AI nie znalazło pasujących tematów" - unavailable: "Wyniki AI są niedostępne" - ai_generated_result: "Wynik wyszukiwania znaleziony przy użyciu AI" - ai_artifact: - expand_view_label: "Rozszerz widok" - collapse_view_label: "Wyjdź z trybu pełnoekranowego (przycisk ESC lub Wstecz)" - ai_bot: - persona: "Osobowość" - llm: "Model" - pm_warning: "Wiadomości wysyłane przez chatboty AI są regularnie monitorowane przez moderatorów." - cancel_streaming: "Zatrzymaj odpowiedź" - default_pm_prefix: "[PW bota AI bez tytułu]" - shortcut_title: "Rozpocznij PW z botem AI" - share: "Skopiuj rozmowę AI" - conversation_shared: "Rozmowa skopiowana" - debug_ai_modal: - request: "Żądanie" - response: "Odpowiedź" - next_log: "Następna" - previous_log: "Poprzedni" - share_ai_conversation: - name: "Udostępnij rozmowę AI" - invite_ai_conversation: - button: "Zaproś" - ai_label: "AI" - ai_title: "Rozmowa z AI" - share_modal: - title: "Skopiuj rozmowę AI" - copy: "Kopiuj" - context: "Interakcje do udostępnienia:" - share_tip: "Alternatywnie możesz udostępnić całą rozmowę" - bot_names: - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - conversations: - today: "Dzisiaj" - last_7_days: "Ostatnie 7 dni" - last_30_days: "Ostatnie 30 dni" - sentiments: - dashboard: - title: "Sentiment" - sentiment_analysis: - share_chart: "Skopiuj link do wykresu" - filter_types: - all: "Wszystkie" - positive: "Pozytywne" - neutral: "Neutralne" - negative: "Negatywne" - group_types: - category: "Kategoria" - tag: "Tag" - table: - sentiment: "Sentiment" - total_count: "Łącznie" - summarization: - chat: - title: "Podsumuj wiadomości" - description: "Wybierz opcję poniżej, aby podsumować rozmowę wysłaną w żądanym przedziale czasowym." - summarize: "Podsumuj" - since: - one: "Ostatnia godzina" - few: "Ostatnie %{count} godziny" - many: "Ostatnie %{count} godzin" - other: "Ostatnie %{count} godzin" - topic: - title: "Podsumowanie tematu" - close: "Zamknij panel podsumowania" - topic_list_layout: - button: - expanded: "Rozszerzony" - expanded_description: "z podsumowaniami AI" - discobot_discoveries: - main_title: "Odkrycia Discobota" - regular_results: "Tematy" - tell_me_more: "Powiedz mi więcej..." - continue_convo: "Kontynuuj rozmowę..." - loading_convo: "Ładowanie konwersacji" - collapse: "Zwiń" - tooltip: - header: "Wyszukiwanie oparte na AI" - content: "Wyszukiwanie w języku naturalnym obsługiwane przez %{model}" - actions: - info: "Jak to działa?" - disable: "Wyłącz" - review: - types: - reviewable_ai_post: - title: "Post oflagowany przez AI" - reviewable_ai_chat_message: - title: "Wiadomość na czacie oflagowana przez AI" diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml deleted file mode 100644 index 6b67474c..00000000 --- a/config/locales/client.pt.yml +++ /dev/null @@ -1,181 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -pt: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Ordenar por" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID do Tópico" - title: - label: "Título" - categories: - label: "Categorias" - tags: - label: "Etiquetas" - llm_triage: - fields: - category: - label: "Categoria" - tags: - label: "Etiquetas" - canned_reply: - label: "Responder" - discourse_ai: - features: - back: "Retroceder" - disabled: "(desativado)" - groups: "Grupos:" - no_persona: "Não definido" - no_groups: "Nenhuma" - edit: "Editar" - expand_list: - one: "(%{count} mais)" - other: "(%{count} mais)" - collapse_list: "(mostrar menos)" - filters: - all: "Tudo" - reset: "Repor" - search: - name: "Pesquisar" - spam: - name: "Spam" - modals: - select_option: "Selecione uma opção..." - spam: - short_title: "Spam" - last_seven_days: "Últimos 7 Dias" - enable: "Ativar" - test_modal: - spam: "Spam" - usage: - summary: "Resumo" - username: "Nome de Utilizador" - total_requests: "Total de pedidos" - periods: - last_day: "Últimas 24 horas" - custom: "Personalizar..." - ai_persona: - back: "Retroceder" - name: "Nome" - edit: "Editar" - export: "Exportar" - description: "Descrição" - user: Utilizador - save: "Guardar" - enabled: "Ativado?" - allowed_groups: "Grupos permitidos" - delete: "Eliminar" - response_format: - open_modal: "Editar" - modal: - key_title: "Chave" - filters: - reset: "Repor" - rag: - uploads: - title: "Uploads" - uploading: "A enviar…" - tools: - back: "Retroceder" - export: "Exportar" - name: "Nome" - description: "Descrição" - summary: "Resumo" - save: "Guardar" - remove_parameter: "Remover" - parameter_required: "Necessário" - edit: "Editar" - delete: "Eliminar" - llms: - display_name: "Nome" - save: "Guardar" - edit: "Editar" - back: "Retroceder" - delete: Eliminar - quotas: - group: "Grupo" - max_usages: "Máximo de utilizações" - duration: "Duração" - durations: - hour: "1 hora" - six_hours: "6 horas" - day: "24 horas" - week: "7 dias" - custom: "Personalizar..." - hours: "horas" - usage: - ai_summarization: "Resumir" - ai_spam: "Spam" - next: - title: "Próximo" - providers: - google: "Google" - fake: "Personalizar" - ai_helper: - context_menu: - cancel: "Cancelar" - discard: "Descartar" - post_options_menu: - close: "Fechar" - copy: "Copiar" - copied: "Copiado!" - cancel: "Cancelar" - thumbnail_suggestions: - select: "Selecionar" - image_caption: - save_caption: "Guardar" - automatic_caption_dialog: - confirm: "Ativar" - embeddings: - back: "Retroceder" - save: "Guardar" - delete: "Eliminar" - edit: "Editar" - display_name: "Nome" - providers: - google: "Google" - fake: "Personalizar" - ai_bot: - debug_ai_modal: - request: "Pedido" - response: "Resposta" - next_log: "Próximo" - previous_log: "Anterior" - invite_ai_conversation: - button: "Convidar" - share_modal: - copy: "Copiar" - conversations: - today: "Hoje" - last_7_days: "Últimos 7 Dias" - last_30_days: "Últimos 30 Dias" - sentiments: - sentiment_analysis: - filter_types: - all: "Tudo" - group_types: - category: "Categoria" - table: - total_count: "Total" - summarization: - chat: - summarize: "Resumir" - discobot_discoveries: - regular_results: "Tópicos" - collapse: "Colapsar" - tooltip: - actions: - disable: "Desativar" diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml deleted file mode 100644 index 1ca28002..00000000 --- a/config/locales/client.pt_BR.yml +++ /dev/null @@ -1,686 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -pt_BR: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Permite pesquisa com IA" - stream_completion: "Permite transmissão de conclusão com persona de IA" - site_settings: - categories: - discourse_ai: "Discourse IA" - dashboard: - emotion: - title: "Gesto" - description: "Esta tabela exibe uma contagem de postagens classificadas com um determinado gesto, com o modelo \"SamLowe/roberta-base-go_emotions\"" - reports: - filters: - sort_by: - label: "Ordenar por" - tag: - label: "Etiqueta" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Remetente" - description: "O(a) usuário(a) enviará o relatório" - receivers: - label: "Destinatários(as)" - description: "Os(as) usuários(as) que receberão o relatório (os e-mails serão enviados diretamente por e-mail, e os nomes de usuário(a) por MP)" - topic_id: - label: "ID do tópico" - description: "O ID do tópico no qual postar o relatório" - title: - label: "Título" - description: "O título do relatório" - days: - label: "Dias" - description: "O tempo de duração do relatório" - offset: - label: "Deslocamento" - description: "Durante o teste, você pode querer executar o relatório com base no histórico, use o deslocamento para iniciar o relatório numa data anterior" - instructions: - label: "Instruções" - description: "As instruções fonrecidas ao modelo de linguagem grande" - sample_size: - label: "Tamanho da amostra" - description: "A quantidade de postagens para fazer amostragem para o relatório" - tokens_per_post: - label: "Tokens por postagem" - description: "A quantidade de tokens de LLM para usar por postagem" - model: - label: "Modelo" - description: "O LLM para usar na geração de relatórios" - categories: - label: "Categorias" - description: "Filtrar tópicos apenas para estas categorias" - tags: - label: "Etiquetas" - description: "Filtrar tópicos apenas para estas etiquetas" - exclude_tags: - label: "Excluir etiquetas" - description: "Excluir tópicos com estas etiquetas" - exclude_categories: - label: "Excluir categorias" - description: "Excluir tópicos com estas categorias" - allow_secure_categories: - label: "Permitir categorias seguras" - description: "Permitir que o relatório seja gerado para tópicos em categorias seguras" - suppress_notifications: - label: "Suprimir notificações" - description: "Suprima notificações que podem ser geradas pelo relatório ao transformar em conteúdo. As menções e links internos serão remapeados." - debug_mode: - label: "Modo de depuração" - description: "Ativar o modo de depuração para ver as entradas e saídas não processadas do LLM" - priority_group: - label: "Grupo de prioridade" - description: "Priorizar o conteúdo deste grupo no relatório" - temperature: - label: "Temperatura" - top_p: - label: "Maior P" - llm_tool_triage: - fields: - model: - label: "Modelo" - llm_triage: - fields: - system_prompt: - label: "Prompt do sistema" - description: "O prompt que será usado para triagem, verifique se responderá com uma única palavra que pode ser usada para acionar a ação" - max_post_tokens: - label: "Máximo de Tokens de Postagem" - description: "A quantidade máxima de tokens para ler usando triagem LLM" - stop_sequences: - label: "Parar sequências" - description: "Ordene ao modelo que interrompa a geração de tokens ao atingir um destes valores" - search_for_text: - label: "Pesquisar texto" - description: "Se o texto a seguir aparecer na resposta do LLM, aplicar estas ações" - category: - label: "Categoria" - description: "Categoria para aplicar no tópico" - tags: - label: "Etiquetas" - description: "Etiquetas para aplicar no tópico" - canned_reply: - label: "Responder" - description: "Texto não processado da resposta pré-preparado para postar no tópico" - canned_reply_user: - label: "Usuário(a) de resposta" - description: "O nome de usuário(a) para postar a resposta pré-preparada" - hide_topic: - label: "Ocultar tópico" - description: "Ocultar visibilidade do tópico para o público se for ativado" - flag_type: - label: "Tipo de sinalizador" - description: "O tipo de sinalizador a ser aplicado na postagem (spam ou sinalização para revisão)" - flag_post: - label: "Sinalizar postagem" - description: "Sinaliza a postagem (como spam ou para revisão)" - include_personal_messages: - label: "Incluir mensagens pessoais" - description: "Também verificar e fazer triagem de mensagens pessoais" - model: - label: "Modelo" - description: "Modelo de linguagem usado para triagem" - temperature: - label: "Temperatura" - discourse_ai: - title: "IA" - features: - back: "Voltar" - disabled: "(desativada)" - groups: "Grupos:" - no_persona: "Não configurado" - no_groups: "Nenhum" - edit: "Editar" - expand_list: - one: "(mais %{count})" - other: "(mais %{count})" - collapse_list: "(exibir menos)" - filters: - all: "Tudo" - reset: "Redefinir" - search: - name: "Pesquisar" - embeddings: - name: "Incorporações" - ai_helper: - name: "Ajudante" - proofread: Revisar texto - explain: "Explicar" - smart_dates: "Datas inteligentes" - markdown_tables: "Gerar tabela de Markdown" - custom_prompt: "Prompt personalizado" - spam: - name: "Spam" - description: "Identifica possíveis spams usando o LLM selecionado e os sinaliza para inspeção pela moderação do site na fila de revisão" - modals: - select_option: "Selecione uma opção..." - spam: - short_title: "Spam" - title: "Configurar tratamento de spam" - select_llm: "Selecionar LLM" - custom_instructions: "Instruções personalizadas" - custom_instructions_help: "Instruções específicas para seu site para ajudar a orientar a IA na identificação de spam, por exemplo: \"Verificar com mais agressividade postagens que não estão em inglês\"." - last_seven_days: "Últimos sete dias" - scanned_count: "Postagens verificadas" - false_positives: "Sinalização incorreta" - false_negatives: "Spam perdido" - spam_detected: "Spam detectado" - custom_instructions_placeholder: "Instruções específicas para o site para ajudar a IA a identificar spam com mais precisão" - enable: "Ativar" - spam_tip: "As três primeiras postagens dos(as) novos(as) usuários(as) nos tópicos públicos serão verificadas pela detecção de spam por IA. Serão sinalizadas para análise e, se forem spam, os(as) usuários(as) serão bloqueados(as)." - settings_saved: "Configurações salvas" - spam_description: "Identifica possíveis spams usando o LLM selecionado e os sinaliza para inspeção pela moderação do site na fila de revisão" - no_llms: "Nenhum LLM disponível" - test_button: "Testar..." - save_button: "Salvar alterações" - test_modal: - title: "Testar detecção de spam" - post_url_label: "Postar URL ou ID" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 ou ID da postagem" - result: "Resultado" - scan_log: "Registro de verificação" - run: "Rodar teste" - spam: "Spam" - not_spam: "Não é spam" - stat_tooltips: - incorrectly_flagged: "Itens que o bot de IA sinalizou como spam, mas a moderação discordou" - missed_spam: "Itens sinalizados como spam pela comunidade, mas que não foram detectados pelo bot de IA, e a moderação concordou" - errors: - scan_not_admin: - message: "Aviso: a verificação de spam não funcionará corretamente porque a conta de verificação de spam não é administrador(a)" - action: "Corrigir" - resolved: "O erro foi solucionado!" - usage: - short_title: "Uso" - summary: "Resumo" - total_tokens: "Tokens totais" - tokens_over_time: "Tokens ao longo do tempo" - features_breakdown: "Uso por recurso" - feature: "Recurso" - usage_count: "Contagem de usos" - model: "Modelo" - models_breakdown: "Uso por modelo" - users_breakdown: "Uso por usuário(a)" - all_features: "Todos os recursos" - all_models: "Todos os modelos" - username: "Nome de usuário(a)" - total_requests: "Pedidos totais" - request_tokens: "Tokens de pedido" - response_tokens: "Tokens de resposta" - net_request_tokens: "Tokens de pedido de rede" - cached_tokens: "Tokens em cache" - cached_request_tokens: "Tokens de pedido em cache" - no_users: "Sem dados de uso do(a) usuário(a) encontrados" - no_models: "Sem dados de uso de modelo encontrados" - no_features: "Sem dados de uso de recurso encontrados" - subheader_description: "Tokens são unidades básicas usadas por LLMs para compreender e gerar texto. Os dados de uso podem afetar o custo" - stat_tooltips: - total_requests: "Todos os pedidos feitos para LLMs através do Discourse" - total_tokens: "Todos os tokens usados ao enviar comandos para um LLM" - request_tokens: "Tokens usados quando o LLM tentar compreender o que você está dizendo" - response_tokens: "Tokens usados quando o LLM responder ao seu comando" - cached_tokens: "Tokens de pedido processados anteriormente reutilizados pelo LLM para otimizar o desempenho e reduzir custo" - periods: - last_day: "Últimas 24 horas" - last_week: "Semana passada" - last_month: "Mês passado" - custom: "Personalizado..." - ai_persona: - ai_tools: "Ferramentas" - tool_strategies: - all: "Aplicar a todas as respostas" - replies: - one: "Aplicar apenas à primeira resposta" - other: "Aplicar às primeiras %{count} respostas" - back: "Voltar" - name: "Nome" - edit: "Editar" - export: "Exportar" - description: "Descrição" - no_llm_selected: "Nenhum modelo de linguagem selecionado" - max_context_posts: "Máximo de postagens de contexto" - max_context_posts_help: "A quantidade máxima de postagens para usar como contexto para a IA ao responder ao(à) usuário(a). (deixar vazio para padrão)" - vision_enabled: Visão ativada - vision_enabled_help: Ative para a IA tentar entender as imagens postadas pelos(as) usuários(as) no tópico conforme o modelo usado na visão compatível. É compatível com os modelos mais recentes do Anthropic, Google e OpenAI. - vision_max_pixels: Tamanho de imagem compatível - vision_max_pixel_sizes: - low: Baixa qualidade - menor custo (256x256) - medium: Média qualidade (512x512) - high: Alta qualidade - mais lento (1024x1024) - tool_details: Exibir detalhes da ferramenta - tool_details_help: Serão exibidos aos(às) usuários(as) finais as ferramentas nas quais o modelo de linguagem foi acionado. - mentionable: Permitir menções - mentionable_help: Ative para que os(as) usuários(as) nos grupos permitidos possam mencionar este(a) usuário(a) nas postagens. A IA responderá como esta persona. - user: Usuário(a) - create_user: Criar usuário(a) - create_user_help: Como alternativa, você poderá anexar um(a) usuário(a) a esta persona. Se fizer isso, a IA usará este(a) usuário(a) para responder aos pedidos. - default_llm: Modelo de linguagem padrão - default_llm_help: O modelo de linguagem padrão a ser usado para esta persona. É obrigatório se você quiser mencionar a persona em postagens públicas. - question_consolidator_llm: Modelo de linguagem para consolidador de pergunta - question_consolidator_llm_help: O modelo de linguagem a ser usado para o consolidador de pergunta. Para economizar, você pode escolher um modelo menos robusto. - system_prompt: Prompt do sistema - forced_tool_strategy: Estratégia de ferramenta forçada - allow_chat_direct_messages: "Permitir mensagens diretas do chat" - allow_chat_direct_messages_help: "Ative para que os(às) usuários(as) nos grupos permitidos possam enviar mensagens diretas a esta persona." - allow_chat_channel_mentions: "Permitir menções no canal de chat" - allow_chat_channel_mentions_help: "Ative para os(as) usuários(as) nos grupos permitidos poderem mencionar esta persona nos canais de chat" - allow_personal_messages: "Permitir mensagens pessoais" - allow_personal_messages_help: "Ative para que os(as) usuários(as) nos grupos permitidos possam enviar mensagens pessoais a esta persona." - allow_topic_mentions: "Permtir menções de tópicos" - allow_topic_mentions_help: "Ative para os(as) usuários(as) nos grupos permitidos poderem mencionar esta persona nos tópicos." - force_default_llm: "Usar sempre o modelo de linguagem padrão" - save: "Salvar" - saved: "Persona salva" - enabled: "Ativado(a)?" - tools: "Ferramentas ativadas" - forced_tools: "Ferramentas forçadas" - allowed_groups: "Grupos permitidos" - confirm_delete: "Você tem certeza de que deseja excluir esta persona?" - new: "Nova persona" - no_personas: "Você ainda não criou nenhuma persona" - title: "Personas" - short_title: "Personas" - delete: "Excluir" - temperature: "Temperatura" - temperature_help: "A Temperatura a ser usada para o LLM. Aumente para incrementar a criatividade (deixe vazio para usar o padrão do modelo, que geralmente é um valor que varia entre 0.0 e 2.0)" - top_p: "P Máximo" - top_p_help: "O P Máximo a ser usado para o LLM, aumente para incrementar o fator aleatório (deixe vazio para usar o padrão do modelo, que geralmente é um valor que varia entre 0.0 e 1.0)" - priority: "Prioridade" - priority_help: "Personas de prioridade são exibidas aos(às) usuários(as) no topo da lista de personas. Se várias personas tiverem prioridade, serão escolhidas em ordem alfabética." - tool_options: "Opções de ferramenta" - rag_conversation_chunks: "Pesquisar pedaços de conversa" - rag_conversation_chunks_help: "O número de pedaços a serem usados para pesquisas de modelo RAG. Aumente para incrementar a quantidade de contexto que a IA pode usar." - persona_description: "Personas são um recurso poderoso que permite personalizar o comportamento da engine de IA no seu fórum do Discourse. Atuam como uma \"mensagem de sistema\" que orienta as respostas e as interações da IA, ajudando a criar uma experiência mais personzalidada e envolvente para o(a) usuário(a)." - response_format: - open_modal: "Editar" - modal: - key_title: "Chave" - filters: - reset: "Redefinir" - rag: - options: - rag_chunk_tokens: "Enviar tokens de pedaço" - rag_chunk_tokens_help: "O número de tokens a ser usado para cada pedaço no modelo RAG. Aumente para incrementar a quantidade de contexto que a IA pode usar. (Altere para indexar novamente todos os envios)" - rag_chunk_overlap_tokens: "Carregar tokens de sobreposição de pedaço" - rag_chunk_overlap_tokens_help: "A quantidade de tokens a serem sobrepostos entre as partes no modelo RAG. (Altere para indexar novamente todos os envios)" - show_indexing_options: "Exibir opções de envio" - hide_indexing_options: "Ocultar opções de envio" - uploads: - title: "Envios" - button: "Adicionar arquivos" - filter: "Filtrar envios" - indexed: "Indexado(a)" - indexing: "Indexação" - uploaded: "Pronto(a) para indexação" - uploading: "Enviando..." - remove: "Remover envio" - tools: - back: "Voltar" - short_title: "Ferramentas" - export: "Exportar" - no_tools: "Você ainda não criou nenhuma ferramenta" - name: "Nome" - new: "Nova ferramenta" - description: "Descrição" - description_help: "Descrição clara da finalidade da ferramenta para o modelo de linguagem" - subheader_description: "As ferramentas extendem as funcionalidades dos bots de IA com funções de JavaScript definidas pelo(a) usuário(a)" - summary: "Resumo" - summary_help: "Resumo das finalidades das ferramentas a ser exibido para usuários(as) finais" - script: "Script" - parameters: "Parâmetros" - save: "Salvar" - remove_parameter: "Remover" - parameter_required: "Necessário(a)" - parameter_enum: "Enumeração" - parameter_name: "Nome do parâmetro" - parameter_description: "Descrição do parâmetro" - enum_value: "Valor da enumeração" - add_enum_value: "Adicionar valor da enumeração" - edit: "Editar" - test: "Rodar teste" - delete: "Excluir" - saved: "Ferramenta salva" - confirm_delete: "Tem certeza de que deseja excluir esta ferramenta?" - test_modal: - title: "Ferramenta de IA de teste" - run: "Rodar teste" - result: "Resultado do teste" - llms: - short_title: "LLMs" - no_llms: "Nenhum LLM ainda" - new: "Novo modelo" - display_name: "Nome" - name: "ID do modelo" - provider: "Provedor" - tokenizer: "Tokenizador" - url: "URL do serviço da hospedagem do modelo" - api_key: "Chave de API do serviço da hospedagem do modelo" - enabled_chat_bot: "Permitir seletor de bot de IA" - vision_enabled: "Visão ativada" - ai_bot_user: "Usuário(a) de bot de IA" - save: "Salvar" - edit: "Editar" - saved: "Modelo de LLM salvo" - back: "Voltar" - confirm_delete: Tem certeza de que deseja excluir este modelo? - delete: Excluir - seeded_warning: "Este modelo foi pré-configurado no seu site e não pode ser editado." - quotas: - title: "Cotas de uso" - add_title: "Criar nova cota" - group: "Grupo" - max_tokens: "Máx. de tokens" - max_usages: "Máximo de usos" - duration: "Duração" - confirm_delete: "Tem certeza de que deseja excluir esta cota?" - add: "Adicionar cota" - durations: - hour: "1 hora" - six_hours: "6 horas" - day: "24 horas" - week: "Sete dias" - custom: "Personalizado..." - hours: "horas" - max_tokens_help: "Quantidade máxima de tokens (palavras e caracteres) que podem ser usados por cada usuário(a) neste grupo durante o período especificado. Tokens são unidades usadas para modelos de IA processarem texto: 1 token equivale a aproximadamente 4 caracteres ou 3/4 de uma palavra." - max_usages_help: "A quantidade máxima de vezes que cada usuário(a) neste grupo pode usar o modelo de IA durante o período especificado. Essa cota é rastreada por cada usuário(a) individual e não é compartilhada no grupo." - usage: - ai_bot: "Bot de IA" - ai_helper: "Ajudante" - ai_persona: "Persona (%{persona})" - ai_summarization: "Resumir" - ai_embeddings_semantic_search: "Pesquisa com IA" - ai_spam: "Spam" - in_use_warning: - one: "Este modelo é usado atualmente por %{settings}. Se configurado incorretamente, o recurso não funcionará como esperado." - other: "Este modelo é usado atualmente por %{settings}. Se configurado incorretamente, os recursos não funcionarão como esperado. " - model_description: - none: "Configurações gerais que funcionam com a maioria dos modelos de linguagens" - anthropic-claude-opus-4-0: "Modelo mais inteligente da Anthropic" - anthropic-claude-3-5-haiku-latest: "Rápido e econômico" - google-gemini-2-5-flash: "Modelo leve, rápido e econômico com raciocínio multimodal" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Modelo multi-idioma leve e eficiente" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Modelo multifinalidade poderoso" - mistral-mistral-large-latest: "Modelo mais poderoso da Mistral" - mistral-pixtral-large-latest: "Modelo mais poderoso com capacidade de visão da Mistral" - preseeded_model_description: "Modelo de código aberto pré-configurado que utiliza %{model}" - configured: - title: "LLMs configuradas" - preconfigured_llms: "Selecione sua LLM" - preconfigured: - title_no_llms: "Selecione um modelo para começar" - title: "Modelos LLM não configurados" - description: "LLMs (Large Language Models) são ferramentas de AI otimizadas para tarefas como resumo de conteúdo, geração de relatórios, automatização de interações com cliente, além de ideias e moderação facilitada de fóruns" - fake: "Configuração manual" - button: "Configurar" - next: - title: "Próximo" - tests: - title: "Rodar teste" - running: "Executando teste..." - success: "Sucesso!" - failure: "Erro retornado ao tentar entrar em contato com o modelo: %{error}" - hints: - name: "Incluímos na chamada da API para especificar qual modelo será usado" - vision_enabled: "Ative para a IA tentar entender as imagens. Depende do modelo usado na visão compatível. É compatível com os modelos mais recentes do Anthropic, Google e OpenAI." - enabled_chat_bot: "Ative para os(as) usuários(as) podesem selecionar este modelo ao criar PM com bot de IA" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Personalizados(as)" - provider_fields: - access_key_id: "ID da chave de acesso do AWS Bedrock" - region: "Região do AWS Bedrock" - organization: "ID da organização opcional da OpenAI" - disable_system_prompt: "Desativar mensagens de sistema nos prompts" - enable_native_tool: "Ativar suporte para ferramenta nativa" - disable_native_tools: "Desativar suporte para ferramenta nativa (usar ferramentas baseadas em XML)" - provider_order: "Ordem de provedores (lista separada por vírgula)" - provider_quantizations: "Ordem de quantizações de provedores (lista separada por vírgula, por exemplo: fp16,fp8)" - disable_streaming: "Desativar conclusões de transmissão (converter transmissão em pedidos que não forem desse tipo)" - related_topics: - title: "Tópicos relacionados" - pill: "Relacionado(a)" - ai_helper: - title: "Sugerir alterações com IA" - description: "Escolha uma das opções abaixo, e a IA irá sugerir uma nova versão do texto." - selection_hint: "Dica: antes de abrir o ajudante, você pode selecionar uma parte do texto para reescrever apenas ela." - suggest: "Sugestão com IA" - suggest_errors: - too_many_tags: - one: "Você pode ter até %{count} etiqueta" - other: "Você pode ter até %{count} etiquetas" - no_suggestions: "Sem sugestões disponíveis" - missing_content: "Insira conteúdo para gerar sugestões." - context_menu: - trigger: "Perguntar à IA" - loading: "A IA está gerando conteúdo" - cancel: "Cancelar" - confirm: "Confirmar" - discard: "Descartar" - changes: "Edições sugeridas" - custom_prompt: - title: "Prompt personalizado" - placeholder: "Insira um prompt personalizado..." - submit: "Enviar prompt" - translate_prompt: "Traduzir para %{language}" - post_options_menu: - trigger: "Perguntar à IA" - title: "Perguntar à IA" - loading: "A IA está gerando" - close: "Fechar" - copy: "Copiar" - copied: "Copiou!" - cancel: "Cancelar" - insert_footnote: "Adicionar nota de rodapé" - footnote_disabled: "Inserção automática desativada, clique no botão de copiar e edite-a manualmente" - footnote_credits: "Explicação da IA" - fast_edit: - suggest_button: "Sugerir edição" - thumbnail_suggestions: - title: "Miniaturas sugeridas" - select: "Selecionar" - selected: "Selecionado(a)" - image_caption: - button_label: "Legenda com IA" - generating: "Gerando legenda..." - credits: "Legendado por IA" - save_caption: "Salvar" - automatic_caption_setting: "Ativar legenda automática" - automatic_caption_loading: "Legendando imagens..." - automatic_caption_dialog: - prompt: "Esta postagem contém imagens sem legendas. Gostaria de ativar legendas automáticas em envios de imagem? (Altere nas preferências mais tarde)" - confirm: "Ativar" - cancel: "Não perguntar novamente" - no_content_error: "Primeiro adicione conteúdo para realizar ações de IA nele" - reviewables: - model_used: "Modelo usado:" - accuracy: "Precisão:" - embeddings: - short_title: "Incorporações" - new: "Nova incorporação" - back: "Voltar" - save: "Salvar" - saved: "Configuração de incorporação salva" - delete: "Excluir" - confirm_delete: Deseja mesmo remover esta configuração de incorporação? - empty: "Você ainda não definiu nenhuma incorporação" - presets: "Selecione uma predefinição..." - configure_manually: "Configure manualmente" - edit: "Editar" - seeded_warning: "Isto foi pré-configurado no seu site e não pode ser editado." - tests: - title: "Rodar teste" - running: "Rodando teste..." - success: "Sucesso!" - failure: "Tentando gerar resultado incorporado em %{error}" - hints: - dimensions_warning: "Ao ser salvo, este valor não poderá ser alterado." - matryoshka_dimensions: "Define o tamanho das incorporações aninhadas usadas para representação de dados de forma hierárquica ou multicamada, parecido com o aninhamento de bonecas umas nas outras." - sequence_length: "A quantidade máxima de tokens que podem ser processados de uma vez ao criar incorporações ou manipular uma consulta." - distance_function: "Determina como a semelhança entre incorporações é calculada, usando distância do cosseno (medida do ângulo entre vetores) ou o produto interno negativo (medida da sobreposição de valores de vetores)" - display_name: "Nome" - provider: "Provedor" - url: "URL de serviço de incorporações" - api_key: "Chave de serviço de incorporações" - tokenizer: "Tokenizador" - dimensions: "Dimensões de incorporação" - max_sequence_length: "Tamanho da sequência" - embed_prompt: "Incorporar comando" - search_prompt: "Procurar comando" - matryoshka_dimensions: "Dimensões de Matryoshka" - distance_function: "Função de distância" - distance_functions: - "<#>": "Produto interno negativo" - <=>: "Distância do cosseno" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Personalizados(as)" - provider_fields: - model_name: "Nome do modelo" - semantic_search: "Tópicos (semântica)" - semantic_search_loading: "Pesquisando mais resultados usando IA" - semantic_search_results: - toggle: "Mostrando %{count} resultados encontrados usando IA" - toggle_hidden: "Ocultando %{count} resultados encontrados usando IA" - none: "Desculpe, nossa Pesquisa com IA não achou tópicos correspondentes" - new: "Aperte \"Pesquisar\"' para começar a procurar resultados novos com a IA" - unavailable: "Resultados de IA indisponíveis" - semantic_search_tooltips: - results_explanation: "Ao ativar esta opção, os resultados de pesquisa com IA são adicionados abaixo." - invalid_sort: "Os resultados das pesquisas devem ser classificados por ordem de relevância para exibir resultados com IA" - semantic_search_unavailable_tooltip: "Os resultados das pesquisas devem ser classificados por ordem de relevância para exibir resultados com IA" - ai_generated_result: "Resultado de pesquisa encontrado usando IA" - quick_search: - suffix: "em todos os tópicos e postagens com IA" - ai_artifact: - expand_view_label: "Expandir visualização" - collapse_view_label: "Sair de tela cheia (ESC ou botão de voltar)" - click_to_run_label: "Rodar artefato" - ai_bot: - llm: "Modelo" - pm_warning: "Todas as mensagens do chatbot de IA são monitoradas regularmente por moderadores(as)." - cancel_streaming: "Parar resposta" - default_pm_prefix: "[MP de bot de IA não identificado]" - shortcut_title: "Iniciar uma MP com bot de IA" - share: "Copiar conversa com IA" - conversation_shared: "Conversação copiada" - debug_ai: "Visualizar resposta e pedido de IA não processado" - debug_ai_modal: - title: "Visualizar interação com IA" - copy_request: "Copiar solicitação" - copy_response: "Copiar resposta" - request_tokens: "Tokens de pedido:" - response_tokens: "Tokens de resposta:" - request: "Pedir" - response: "Resposta" - next_log: "Próximo" - previous_log: "Anterior" - share_full_topic_modal: - title: "Compartilhar conversas publicamente" - share: "Compartilhar e copiar link" - update: "Enviar e copiar link" - delete: "Excluir compartilhamento" - share_ai_conversation: - name: "Compartilhar conversação com IA" - title: "Compartilhar esta conversa com IA publicamente" - invite_ai_conversation: - button: "Convite" - ai_label: "IA" - ai_title: "Conversação com IA" - share_modal: - title: "Copiar conversa com IA" - copy: "Copiar" - context: "Interações para compartilhar:" - share_tip: "Como alternativa, você pode compartilhar toda esta conversa" - bot_names: - fake: "Bot de teste simulado" - claude-3-opus: "Opus Claude 3" - claude-3-sonnet: "Sonnet Claude 3" - claude-3-haiku: "Haiku Claude 3" - cohere-command-r-plus: "Command R Plus Cohere" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Hoje" - last_7_days: "Últimos sete dias" - last_30_days: "Últimos 30 dias" - sentiments: - dashboard: - title: "Sentimento" - sentiment_analysis: - filter_types: - all: "Tudo" - positive: "Positivo" - neutral: "Neutro" - negative: "Negativo" - group_types: - category: "Categoria" - tag: "Etiqueta" - table: - sentiment: "Sentimento" - total_count: "Total" - summarization: - chat: - title: "Resumir mensagens" - description: "Selecione uma opção abaixo para resumir a conversa enviada durante o período desejado." - summarize: "Resumir" - since: - one: "Última hora" - other: "Últimas %{count} horas" - topic: - title: "Resumo do tópico" - close: "Fechar painel de resumo" - topic_list_layout: - button: - compact: "Compactar" - expanded: "Expandido(a)" - expanded_description: "com resumos de IA" - discobot_discoveries: - regular_results: "Tópicos" - collapse: "Recolher" - tooltip: - actions: - disable: "Desativar" - review: - types: - reviewable_ai_post: - title: "Postagem sinalizada com IA" - reviewable_ai_chat_message: - title: "Mensagem de chat sinalizada com IA" diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml deleted file mode 100644 index a5c274d2..00000000 --- a/config/locales/client.ro.yml +++ /dev/null @@ -1,184 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ro: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Sortează după" - tag: - label: "Etichetă" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID discuție" - title: - label: "Titlu" - categories: - label: "Categorii" - tags: - label: "Etichete" - llm_triage: - fields: - category: - label: "Categorie" - tags: - label: "Etichete" - canned_reply: - label: "Răspunde" - discourse_ai: - features: - back: "Înapoi" - disabled: "(dezactivat)" - groups: "Grupuri:" - no_groups: "Nimic" - edit: "Modifică" - expand_list: - one: "(încă %{count})" - few: "(încă %{count})" - other: "(încă %{count})" - collapse_list: "(arată mai puține)" - filters: - all: "Toate" - reset: "Resetare" - search: - name: "Căutare" - spam: - name: "Spam" - modals: - select_option: "Alege o opțiune..." - spam: - short_title: "Spam" - last_seven_days: "Ultimele 7 zile" - enable: "Activează" - test_modal: - spam: "Spam" - usage: - summary: "Rezumat" - username: "Nume utilizator" - total_requests: "Total cereri" - periods: - last_day: "Ultimele 24 de ore" - custom: "Personalizat..." - ai_persona: - back: "Înapoi" - name: "Nume" - edit: "Modifică" - export: "Exportă" - description: "Descriere" - user: Utilizatori - save: "Salvare" - enabled: "Activat?" - delete: "Șterge" - response_format: - open_modal: "Modifică" - filters: - reset: "Resetare" - rag: - uploads: - title: "Urcări" - uploading: "Se încarcă..." - tools: - back: "Înapoi" - export: "Exportă" - name: "Nume" - description: "Descriere" - summary: "Rezumat" - save: "Salvează" - remove_parameter: "Elimină" - parameter_required: "Necesare" - edit: "Modifică" - delete: "Șterge" - llms: - display_name: "Nume" - save: "Salvează" - edit: "Modifică" - back: "Înapoi" - delete: Șterge - quotas: - group: "Grup" - duration: "Durată" - durations: - hour: "o oră" - six_hours: "6 de ore" - day: "24 de ore" - custom: "Personalizat..." - hours: "de ore" - usage: - ai_summarization: "Rezumat" - ai_spam: "Spam" - next: - title: "Următorul" - tests: - success: "Succes!" - providers: - google: "Google" - fake: "Personalizat" - ai_helper: - context_menu: - cancel: "Anulare" - discard: "Renunță" - post_options_menu: - close: "Închide sondajul" - copy: "Copiază" - copied: "Copiat!" - cancel: "Anulare" - image_caption: - save_caption: "Salvare" - automatic_caption_dialog: - confirm: "Activează" - embeddings: - back: "Înapoi" - save: "Salvare" - delete: "Șterge" - edit: "Modifică" - tests: - title: "Rulează test" - success: "Succes!" - display_name: "Nume" - providers: - google: "Google" - fake: "Personalizat" - ai_bot: - debug_ai_modal: - request: "Cere" - response: "Răspuns" - next_log: "Următorul" - previous_log: "Precedent" - invite_ai_conversation: - button: "Invită" - share_modal: - copy: "Copiază" - conversations: - today: "Astăzi" - last_7_days: "Ultimele 7 zile" - last_30_days: "Ultimele 30 de zile" - sentiments: - sentiment_analysis: - filter_types: - all: "Toate" - group_types: - category: "Categorie" - tag: "Etichetă" - table: - total_count: "Total" - summarization: - chat: - title: "Rezumați mesajele" - description: "Selectați o opțiune de mai jos pentru a rezuma conversația trimisă în intervalul de timp dorit." - summarize: "Rezumat" - discobot_discoveries: - regular_results: "Discuții" - collapse: "Colaps" - tooltip: - actions: - disable: "Dezactivează" diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml deleted file mode 100644 index f1d20dbc..00000000 --- a/config/locales/client.ru.yml +++ /dev/null @@ -1,696 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ru: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "Разрешает AI-поиск" - stream_completion: "Включает генерацию ответов AI-персоны в реальном времени" - site_settings: - categories: - discourse_ai: "AI для Discourse" - dashboard: - emotion: - title: "Эмоция" - description: "В таблице указано количество публикаций, классифицированных по определенной эмоции. Классификация выполнена с помощью модели 'SamLowe/roberta-base-go_emotions'." - reports: - filters: - sort_by: - label: "Сортировка" - tag: - label: "Теги" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Отправитель" - description: "Пользователь, который отправит отчет" - receivers: - label: "Получатели" - description: "Пользователи, которые получат отчет (электронные письма будут отправлены по электронной почте, имена пользователей будут отправлены в личку)" - topic_id: - label: "ID темы" - description: "Идентификатор темы, в которой будет опубликован отчет" - title: - label: "Название" - description: "Название отчета" - days: - label: "Дни" - description: "Временной интервал отчета" - offset: - label: "Смещение" - description: "При тестировании вы можете запускать отчет с учетом исторических данных, используя смещение начала отчета на более раннюю дату." - instructions: - label: "Инструкции" - description: "Инструкции, предоставленные для большой языковой модели" - sample_size: - label: "Размер выборки" - description: "Количество выбранных публикаций для отчета" - tokens_per_post: - label: "Число лексем на публикацию" - description: "Количество токенов LLM для использования в одной публикации" - model: - label: "Модель" - description: "LLM, используемая для создания отчетов" - categories: - label: "Категории" - description: "Фильтровать темы только по этим категориям" - tags: - label: "Теги" - description: "Фильтровать темы только по этим тегам" - exclude_tags: - label: "Исключить теги" - description: "Исключить темы с этими тегами" - exclude_categories: - label: "Исключить категории" - description: "Исключить темы из этих категорий" - allow_secure_categories: - label: "Разрешить защищенные категории" - description: "Разрешить создание отчета по темам в защищенных категориях" - suppress_notifications: - label: "Отключение уведомлений" - description: "Отключает уведомления, которые может генерировать отчет, путем преобразования их в контент. При этом будут изменены упоминания и внутренние ссылки." - debug_mode: - label: "Режим отладки" - description: "Включить режим отладки для просмотра необработанных входных и выходных данных LLM." - priority_group: - label: "Приоритетная группа" - description: "Приоритизировать контент из этой группы в отчете" - temperature: - label: "Температура" - top_p: - label: "Top P" - llm_tool_triage: - fields: - model: - label: "Модель" - llm_triage: - fields: - system_prompt: - label: "Системный запрос" - description: "Запрос для приоритизации. Для ответа используйте одно слово, которое можно применить для запуска действия." - max_post_tokens: - label: "Макс. число токенов на публикацию" - description: "Максимальное число токенов, которые может обработать LLM во время триажа" - stop_sequences: - label: "Последовательности для остановки" - description: "Дает указание модели остановить генерацию токенов при получении одного из этих значений" - search_for_text: - label: "Поиск текста" - description: "Если в ответе LLM появится следующий текст, применить эти действия" - category: - label: "Категория" - description: "Категория, применяемая к теме" - tags: - label: "Теги" - description: "Теги, применяемые к теме" - canned_reply: - label: "Ответ" - description: "Необработанный текст шаблонного ответа на публикацию по теме" - canned_reply_user: - label: "Ответ пользователя" - description: "Имя пользователя для публикации шаблонного ответа" - hide_topic: - label: "Скрыть тему" - description: "Сделать тему недоступной для широкой аудитории, если срабатывают определенные условия" - flag_type: - label: "Тип метки" - description: "Тип метки, применяемой к публикации (спам или отправка на проверку)" - flag_post: - label: "Пометить публикацию" - description: "Пометить публикацию (как спам или для отправки на проверку)" - include_personal_messages: - label: "Включать личные сообщения" - description: "Также сканировать и выполнять триаж личных сообщений" - model: - label: "Модель" - description: "Языковая модель для приоритизации" - temperature: - label: "Температура" - discourse_ai: - title: "AI" - features: - back: "Назад" - disabled: "(отключен)" - groups: "Группы:" - no_persona: "Не установлен" - no_groups: "Нет" - edit: "Изменить" - expand_list: - one: "(ещё %{count})" - few: "(ещё %{count})" - many: "(ещё %{count})" - other: "(ещё %{count})" - collapse_list: "(свернуть)" - filters: - all: "Все настройки" - reset: "Сбросить" - search: - name: "Искать" - embeddings: - name: "Встраивания" - ai_helper: - name: "Помощник" - proofread: Вычитать текст - explain: "Объяснить" - smart_dates: "Умные даты" - markdown_tables: "Сгенерировать таблицу в формате Markdown" - custom_prompt: "Пользовательский запрос" - spam: - name: "Спам" - description: "С помощью выбранной LLM определяет потенциальный спам и отправляет его модераторам сайта в очередь проверки" - modals: - select_option: "Выберите вариант..." - spam: - short_title: "Спам" - title: "Настройте обработку спама" - select_llm: "Выберите LLM" - custom_instructions: "Пользовательские инструкции" - custom_instructions_help: "Пользовательские инструкции для вашего сайта помогут AI выявить спам, например, «Более придирчиво проверяй публикации не на английском языке»." - last_seven_days: "За последние 7 дней" - scanned_count: "Публикации проверены" - false_positives: "Неправильно помечено" - false_negatives: "Пропущенный спам" - spam_detected: "Обнаружен спам" - custom_instructions_placeholder: "Инструкции для AI по конкретным сайтам, чтобы более точно выявлять спам" - enable: "Включить" - spam_tip: "AI-система обнаружения спама будет проверять первые 3 публикации всех новых пользователей в публичных темах. Она пометит их для проверки и заблокирует пользователей, если они, вероятно, являются спамерами." - settings_saved: "Настройки сохранены" - spam_description: "С помощью выбранной LLM определяет потенциальный спам и отправляет его модераторам сайта в очередь проверки" - no_llms: "Нет доступных LLM" - test_button: "Тестирование..." - save_button: "Сохранить изменения" - test_modal: - title: "Протестируйте обнаружение спама" - post_url_label: "URL-адрес или идентификатор публикации" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 или идентификатор публикации" - result: "Результат" - scan_log: "Журнал сканирования" - run: "Запустить тест" - spam: "Спам" - not_spam: "Не спам" - stat_tooltips: - incorrectly_flagged: "Элементы, которые AI-бот пометил как спам, но модераторы с этим не согласились" - missed_spam: "Элементы, помеченные сообществом как спам, необнаруженные AI-ботом, но с которыми согласились модераторы" - errors: - scan_not_admin: - message: "Внимание: проверка на спам будет работать некорректно, потому что аккаунт для выявления спама не является аккаунтом администратора" - action: "Исправить" - resolved: "Ошибка устранена!" - usage: - short_title: "Использование" - summary: "Сводка" - total_tokens: "Всего токенов" - tokens_over_time: "Число токенов с течением времени" - features_breakdown: "Использование по функциям" - feature: "Функция" - usage_count: "Число использований" - model: "Модель" - models_breakdown: "Использование по модели" - users_breakdown: "Использование на пользователя" - all_features: "Все функции" - all_models: "Все модели" - username: "Псевдоним" - total_requests: "Всего запросов" - request_tokens: "Токены запроса" - response_tokens: "Токены ответа" - net_request_tokens: "Токены сетевых запросов" - cached_tokens: "Кешированные токены" - cached_request_tokens: "Кэшированные токены запросов" - no_users: "Данные об использовании пользователем не найдены" - no_models: "Данные об использовании модели не найдены" - no_features: "Данные об использовании функций не найдены" - subheader_description: "Токены — это основные единицы, которые LLM используют для понимания и генерации текста. Данные об использовании могут влиять на стоимость" - stat_tooltips: - total_requests: "Все запросы, направленные LLM через Discourse" - total_tokens: "Все токены, задействованные при запросе к LLM" - request_tokens: "Токены, задействованные при попытке LLM понять, что вы говорите" - response_tokens: "Токены, задействованные при ответе LLM на ваш запрос" - cached_tokens: "Ранее обработанные токены запросов, которые LLM повторно использует для оптимизации производительности и стоимости" - periods: - last_day: "За последние 24 часа" - last_week: "На прошлой неделе" - last_month: "В прошлом месяце" - custom: "Другое…" - ai_persona: - ai_tools: "Инструменты" - tool_strategies: - all: "Применить ко всем ответам" - replies: - one: "Применить только к первому ответу" - few: "Применить к первым %{count} ответам" - many: "Применить к первым %{count} ответам" - other: "Применить к стольким первым ответам: %{count}" - back: "Назад" - name: "Название" - edit: "Изменить" - export: "Экспорт" - description: "Описание" - no_llm_selected: "Языковая модель не выбрана" - max_context_posts: "Максимум публикаций для учета в контексте" - max_context_posts_help: "Максимальное количество публикаций, которое будет использоваться в качестве контекста для AI при ответе пользователю. (Пусто по умолчанию)" - vision_enabled: Визуальное распознавание включено - vision_enabled_help: Если этот параметр включен, AI будет пытаться анализировать изображения, размещаемые пользователями в теме, при условии, что используемая модель поддерживает визуальное распознавание изображений. Поддерживается новейшими моделями от Anthropic, Google и OpenAI. - vision_max_pixels: Поддерживаемый размер изображения - vision_max_pixel_sizes: - low: Низкое качество — самое дешевое (256×256) - medium: Среднее качество (512x512) - high: Высокое качество — самое медленное (1024×1024) - tool_details: Показать детали инструмента - tool_details_help: Покажет конечным пользователям сведения о том, какие инструменты запустила языковая модель. - mentionable: Разрешить упоминания - mentionable_help: Если этот параметр включен, пользователи в разрешенных группах смогут упоминать этого пользователя в публикациях, AI будет отвечать от имени этой персоны. - user: Пользователь - create_user: Создать пользователя - create_user_help: При желании к этой персоне можно прикрепить пользователя. В этом случае AI будет использовать этого пользователя для ответа на запросы. - default_llm: Языковая модель по умолчанию - default_llm_help: Языковая модель по умолчанию, используемая для этой персоны. Требуется, если вы хотите упомянуть персону в общедоступных публикациях. - question_consolidator_llm: Языковая модель для консолидатора вопросов - question_consolidator_llm_help: Языковая модель, используемая для консолидатора вопросов; вы можете выбрать менее ресурсозатратную модель для экономии средств. - system_prompt: Системный запрос - forced_tool_strategy: Стратегия обязательного применения инструмента - allow_chat_direct_messages: "Разрешить прямые сообщения в чате" - allow_chat_direct_messages_help: "Если параметр включен, пользователи в разрешенных группах смогут отправлять прямые сообщения этой персоне." - allow_chat_channel_mentions: "Разрешить упоминания в каналах чата" - allow_chat_channel_mentions_help: "Если параметр включен, пользователи в разрешенных группах смогут упоминать эту персону в каналах чата." - allow_personal_messages: "Разрешить личные сообщения" - allow_personal_messages_help: "Если параметр включен, пользователи в разрешенных группах смогут отправлять личные сообщения этой персоне." - allow_topic_mentions: "Разрешить упоминания в темах" - allow_topic_mentions_help: "Если параметр включен, пользователи в разрешенных группах смогут упоминать эту персону в темах." - force_default_llm: "Всегда использовать языковую модель по умолчанию" - save: "Сохранить" - saved: "Персона сохранена" - enabled: "Включено?" - tools: "Включенные инструменты" - forced_tools: "Обязательные инструменты" - allowed_groups: "Разрешённые группы" - confirm_delete: "Точно удалить эту персону?" - new: "Новая персона" - no_personas: "Вы еще не создали ни одной персоны" - title: "Персоны" - short_title: "Персоны" - delete: "Удалить" - temperature: "Температура" - temperature_help: "Температура — параметр для LLM, его увеличение приводит к повышению креативности (оставьте пустым, чтобы использовать настройки модели по умолчанию, стандартные значения от 0.0 до 2.0)" - top_p: "Top P" - top_p_help: "Top P — параметр для LLM, его увеличение приводит к увеличению случайности (оставьте пустым, чтобы использовать настройки модели по умолчанию, стандартные значения от 0.0 до 1.0)" - priority: "Приоритет" - priority_help: "Приоритетные персоны показываются пользователям вверху списка персон. Если приоритет имеют несколько персон, они будут отсортированы в алфавитном порядке." - tool_options: "Параметры инструмента" - rag_conversation_chunks: "Фрагменты разговора для поиска" - rag_conversation_chunks_help: "Количество фрагментов для поиска в модели RAG. Увеличьте это значение, чтобы увеличить объем контекста, который может использовать AI." - persona_description: "Персоны — полезная функция, с помощью которой вы можете настроить поведение движка AI на вашем форуме Discourse. Они действуют как «системное сообщение», задающее направление для ответов AI и обеспечивающее более персонализированное взаимодействие с пользователями." - response_format: - open_modal: "Изменить" - modal: - key_title: "Ключ" - filters: - reset: "Сбросить" - rag: - options: - rag_chunk_tokens: "Токены фрагментов при загрузке" - rag_chunk_tokens_help: "Количество токенов для каждого фрагмента в модели RAG. Увеличьте, чтобы расширить объем контекста для AI. (Изменение параметра приведет к переиндексации всех загрузок)" - rag_chunk_overlap_tokens: "Токены перекрытия фрагментов при загрузке" - rag_chunk_overlap_tokens_help: "Количество токенов для перекрытия между фрагментами в модели RAG. (Изменение параметра приведет к переиндексации всех загрузок)" - show_indexing_options: "Показать параметры загрузки" - hide_indexing_options: "Скрыть параметры загрузки" - uploads: - title: "Загрузки" - button: "Добавить файлы" - filter: "Фильтровать загрузки" - indexed: "Проиндексированные" - indexing: "Индексируются" - uploaded: "Готовые к индексированию" - uploading: "Загрузка…" - remove: "Удалить загрузку" - tools: - back: "Назад" - short_title: "Инструменты" - export: "Экспорт" - no_tools: "Вы еще не создали ни одного инструмента" - name: "Название" - new: "Новый инструмент" - description: "Описание" - description_help: "Четкое описание назначения инструмента для языковой модели" - subheader_description: "Инструменты расширяют возможности AI-ботов с помощью пользовательских функций JavaScript." - summary: "Сводка" - summary_help: "Краткое описание назначения инструментов для отображения конечным пользователям" - script: "Скрипт" - parameters: "Параметры" - save: "Сохранить" - remove_parameter: "Отозвать" - parameter_required: "Обязательное" - parameter_enum: "Перечисление" - parameter_name: "Название параметра" - parameter_description: "Описание параметра" - enum_value: "Значение перечисления" - add_enum_value: "Добавить значение перечисления" - edit: "Изменить" - test: "Запустить тест" - delete: "Удалить" - saved: "Инструмент сохранен" - confirm_delete: "Действительно удалить этот инструмент?" - test_modal: - title: "Тест AI-инструмента" - run: "Запустить тест" - result: "Результат теста" - llms: - short_title: "Большие языковые модели" - no_llms: "Еще нет LLM" - new: "Новая модель" - display_name: "Название" - name: "Идентификатор модели" - provider: "Поставщик" - tokenizer: "Токенизатор" - url: "URL-адрес сервиса, где размещена модель" - api_key: "API-ключ сервиса, где размещена модель" - enabled_chat_bot: "Разрешить выбор AI-бота" - vision_enabled: "Визуальное распознавание включено" - ai_bot_user: "Пользователь AI-бота" - save: "Сохранить" - edit: "Изменить" - saved: "Модель LLM сохранена" - back: "Назад" - confirm_delete: Действительно удалить эту модель? - delete: Удалить - seeded_warning: "Эта модель уже предварительно настроена на вашем сайте и ее нельзя изменить." - quotas: - title: "Квоты на использование" - add_title: "Создайте новую квоту" - group: "Группа" - max_tokens: "Макс. количество токенов" - max_usages: "Максимальное количество использований" - duration: "Период" - confirm_delete: "Действительно удалить эту квоту?" - add: "Добавить квоту" - durations: - hour: "1 час" - six_hours: "6 часов" - day: "24 часа" - week: "7 дней" - custom: "Другое…" - hours: "ч." - max_tokens_help: "Максимальное количество токенов (слов и символов), которые каждый пользователь в этой группе может использовать в течение указанного периода. Токены — это единицы, используемые AI-моделями для обработки текста. 1 токен равен примерно 4 символам или 3/4 слова." - max_usages_help: "Максимальное количество раз, которое каждый пользователь в этой группе может использовать AI-модель в течение указанного периода. Эта квота отслеживается для каждого пользователя, а не распределяется по всей группе." - usage: - ai_bot: "AI-бот" - ai_helper: "Помощник" - ai_persona: "Персона (%{persona})" - ai_summarization: "Сводка" - ai_embeddings_semantic_search: "AI-поиск" - ai_spam: "Спам" - in_use_warning: - one: "Эта модель сейчас используется в параметре «%{settings}». В случае неправильной настройки функция не будет работать должным образом." - few: "Эта модель сейчас используется в следующих параметрах: %{settings}. В случае неправильной настройки функции не будут работать должным образом. " - many: "Эта модель сейчас используется в следующих параметрах: %{settings}. В случае неправильной настройки функции не будут работать должным образом. " - other: "Эта модель сейчас используется в следующих параметрах: %{settings}. В случае неправильной настройки функции не будут работать должным образом. " - model_description: - none: "Общие настройки, подходящие для большинства языковых моделей" - anthropic-claude-opus-4-0: "Самая интеллектуальная модель Anthropic" - anthropic-claude-3-5-haiku-latest: "Быстрая и экономически эффективная модель" - google-gemini-2-5-flash: "Легкая, быстрая и экономически эффективная модель с поддержкой мультимодальных рассуждений" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Эффективная легкая многоязычная модель" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Мощная многофункциональная модель" - mistral-mistral-large-latest: "Самая мощная модель Mistral" - mistral-pixtral-large-latest: "Самая мощная модель Mistral, поддерживающая обработку изображений" - preseeded_model_description: "Предварительно настроенная модель с открытым исходным кодом, использующая %{model}" - configured: - title: "Настроенные LLM" - preconfigured_llms: "Выберите LLM" - preconfigured: - title_no_llms: "Выберите шаблон, чтобы начать" - title: "Ненастроенные шаблоны LLM" - description: "LLM (Large Language Models, большие языковые модели) — это инструменты искусственного интеллекта, оптимизированные для таких задач, как обобщение контента, создание отчетов, автоматизация взаимодействия с клиентами, упрощение модерации форумов и анализ информации" - fake: "Ручная настройка" - button: "Настроить" - next: - title: "Далее" - tests: - title: "Запустить тест" - running: "Запуск теста..." - success: "Успех!" - failure: "При попытке связаться с моделью возникла ошибка: %{error}" - hints: - name: "Мы включаем эти данные в вызов API, чтобы указать, какую модель будем использовать" - vision_enabled: "Если этот параметр включен, AI будет пытаться анализировать изображения, при условии, что используемая модель поддерживает визуальное распознавание изображений. Поддерживается новейшими моделями от Anthropic, Google и OpenAI." - enabled_chat_bot: "Если параметр включен, пользователи могут выбирать эту модель при создании личных сообщений с помощью AI-бота" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Другое" - provider_fields: - access_key_id: "Идентификатор ключа доступа к AWS Bedrock" - region: "Регион AWS Bedrock" - organization: "Необязательный идентификатор организации OpenAI" - disable_system_prompt: "Отключить системное сообщение в запросах" - enable_native_tool: "Включить поддержку встроенных инструментов" - disable_native_tools: "Отключить поддержку встроенных инструментов (использовать инструменты на основе XML)" - provider_order: "Порядок поставщиков (список, разделенный запятыми)" - provider_quantizations: "Порядок квантования поставщиков (список, разделенный запятыми, например: fp16,fp8)" - disable_streaming: "Отключить генерацию ответов в реальном времени (преобразовывать потоковые запросы в непотоковые)" - related_topics: - title: "Связанные темы" - pill: "Связанные" - ai_helper: - title: "Предложить изменения с помощью AI" - description: "Выберите один из вариантов ниже, и AI предложит новую версию текста." - selection_hint: "Подсказка: чтобы переписать часть текста, выделите нужный фрагмент до запуска помощника." - suggest: "Подсказки от AI" - suggest_errors: - too_many_tags: - one: "Максимальное количество тегов — %{count}." - few: "Максимальное количество тегов — %{count}." - many: "Максимальное количество тегов — %{count}." - other: "Максимальное количество тегов — %{count}." - no_suggestions: "Нет рекомендаций" - missing_content: "Введите контент для генерации подсказок." - context_menu: - trigger: "Спросить AI" - loading: "AI генерирует ответ" - cancel: "Отменить" - confirm: "Подтвердить" - discard: "Отменить" - changes: "Предлагаемые правки" - custom_prompt: - title: "Пользовательский запрос" - placeholder: "Введите пользовательский запрос..." - submit: "Отправить запрос" - translate_prompt: "Перевести на %{language}" - post_options_menu: - trigger: "Спросить AI" - title: "Спросить AI" - loading: "AI генерирует ответ" - close: "Закрыть" - copy: "Копировать" - copied: "Скопировано!" - cancel: "Отменить" - insert_footnote: "Добавить сноску" - footnote_disabled: "Автоматическая вставка отключена, нажмите кнопку «Копировать» и отредактируйте вручную." - footnote_credits: "Объяснение от AI" - fast_edit: - suggest_button: "Предложить правку" - thumbnail_suggestions: - title: "Рекомендуемые миниатюры" - select: "Выбрать" - selected: "Выбрано" - image_caption: - button_label: "Подпись к изображению от AI" - generating: "Создание подписи..." - credits: "Подпись к изображению от AI" - save_caption: "Сохранить" - automatic_caption_setting: "Включить автоматические подписи" - automatic_caption_loading: "Создание подписей к изображениям..." - automatic_caption_dialog: - prompt: "Эта публикация содержит изображения без подписей. Хотите включить автоматические подписи к загружаемым изображениям? (Это можно будет изменить в настройках позже.)" - confirm: "Включить" - cancel: "Больше не спрашивать" - no_content_error: "Сначала добавьте контент, чтобы выполнить действия с ним с помощью AI" - reviewables: - model_used: "Использованная модель:" - accuracy: "Точность:" - embeddings: - short_title: "Встраивания" - new: "Новое встраивание" - back: "Назад" - save: "Сохранить" - saved: "Конфигурация встраивания сохранена" - delete: "Удалить" - confirm_delete: Действительно удалить эту конфигурацию встраивания? - empty: "Вы еще не настроили встраивание" - presets: "Выберите пресет..." - configure_manually: "Настроить вручную" - edit: "Изменить" - seeded_warning: "Данный параметр уже настроен на вашем сайте и не подлежит изменению." - tests: - title: "Выполнить тест" - running: "Запуск теста..." - success: "Успех!" - failure: "Попытка создать встраивание привела к следующему результату: %{error}" - hints: - dimensions_warning: "После сохранения это значение изменить нельзя." - matryoshka_dimensions: "Определяет размер вложенных встраиваний, используемых для иерархического или многослойного представления данных, подобно тому, как вложены друг в друга матрешки." - sequence_length: "Максимальное количество токенов, которые могут быть обработаны одновременно при создании встраиваний или обработке запроса." - distance_function: "Определяет, как вычисляется сходство между встраиваниями, используя либо косинусное расстояние (измерение угла между векторами), либо отрицательное скалярное произведение (измерение перекрытия значений векторов)." - display_name: "Название" - provider: "Поставщик" - url: "URL-адрес службы встраивания" - api_key: "API-ключ службы встраивания" - tokenizer: "Токенизатор" - dimensions: "Размеры встраивания" - max_sequence_length: "Длина последовательности" - embed_prompt: "Встроенный запрос" - search_prompt: "Поисковый запрос" - matryoshka_dimensions: "Размеры матрёшки" - distance_function: "Функция расстояния" - distance_functions: - "<#>": "Отрицательное скалярное произведение" - <=>: "Косинусное расстояние" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Другое" - provider_fields: - model_name: "Название модели" - semantic_search: "Темы (семантика)" - semantic_search_loading: "Поиск дополнительных результатов с помощью AI" - semantic_search_results: - toggle: "Показать результаты (%{count}), найденные с помощью AI" - toggle_hidden: "Скрыть результаты (%{count}), найденные с помощью AI" - none: "К сожалению, AI-поиск не нашел подходящих тем" - new: "Нажмите «Поиск», чтобы начать поиск новых результатов с помощью AI" - unavailable: "Результаты AI недоступны" - semantic_search_tooltips: - results_explanation: "Если параметр включен, дополнительные результаты AI-поиска будут добавлены ниже." - invalid_sort: "Результаты поиска должны быть отсортированы по релевантности для отображения AI-результатов" - semantic_search_unavailable_tooltip: "Результаты поиска должны быть отсортированы по релевантности для отображения AI-результатов" - ai_generated_result: "Результат поиска найден с помощью AI" - quick_search: - suffix: "во всех темах и публикациях с ИИ" - ai_artifact: - expand_view_label: "Расширить вид" - collapse_view_label: "Выйти из полноэкранного режима (кнопка ESC или «Назад»)" - click_to_run_label: "Запустить артефакт" - ai_bot: - llm: "Модель" - pm_warning: "Сообщения чат-бота с AI регулярно отслеживаются модераторами." - cancel_streaming: "Прекратить отвечать" - default_pm_prefix: "[Личные сообщения от AI-бота без названия]" - shortcut_title: "Начать личный чат с AI-ботом" - share: "Копировать разговор с AI" - conversation_shared: "Разговор скопирован" - debug_ai: "Просмотреть необработанные запросы и ответы AI" - debug_ai_modal: - title: "Просмотр взаимодействий с AI" - copy_request: "Копировать запрос" - copy_response: "Копировать ответ" - request_tokens: "Токены запроса:" - response_tokens: "Токены ответа:" - request: "Запрос" - response: "Ответ" - next_log: "Далее" - previous_log: "Назад" - share_full_topic_modal: - title: "Поделитесь разговором публично" - share: "Поделиться и скопировать ссылку" - update: "Обновить и скопировать ссылку" - delete: "Удалить доступ" - share_ai_conversation: - name: "Поделиться разговором с AI" - title: "Поделитесь этим разговором с AI публично" - invite_ai_conversation: - button: "Пригласить" - ai_label: "AI" - ai_title: "Разговор с AI" - share_modal: - title: "Копировать разговор с AI" - copy: "Копировать" - context: "Поделиться взаимодействиями:" - share_tip: "Вы также можете поделиться всем разговором" - bot_names: - fake: "Поддельный тестовый бот" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Сегодня" - last_7_days: "За последние 7 дней" - last_30_days: "За последние 30 дней" - sentiments: - dashboard: - title: "Настроение" - sentiment_analysis: - filter_types: - all: "Все настройки" - positive: "Позитивное" - neutral: "Нейтральность" - negative: "Негативное" - group_types: - category: "Категория" - tag: "Теги" - table: - sentiment: "Настроение" - total_count: "Всего" - summarization: - chat: - title: "Делать сводку сообщений" - description: "Выберите ниже вариант сводки разговора, отправленного в указанный период." - summarize: "Сводка" - since: - one: "Последний %{count} час" - few: "Последние %{count} часа" - many: "Последние %{count} часов" - other: "Последние %{count} часа" - topic: - title: "Сводка по теме" - close: "Закрыть панель сводки" - topic_list_layout: - button: - compact: "Компактный" - expanded: "Расширенный" - expanded_description: "со сводками от AI" - discobot_discoveries: - regular_results: "Новые темы" - collapse: "Свернуть" - tooltip: - actions: - disable: "Отключить" - review: - types: - reviewable_ai_post: - title: "Публикация с жалобой от AI" - reviewable_ai_chat_message: - title: "Сообщение в чате с жалобой от AI" diff --git a/config/locales/client.sk.yml b/config/locales/client.sk.yml deleted file mode 100644 index 77050b41..00000000 --- a/config/locales/client.sk.yml +++ /dev/null @@ -1,204 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sk: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Zoradiť podľa" - tag: - label: "Značka" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Odosielateľ" - topic_id: - label: "ID témy" - title: - label: "Názov" - categories: - label: "Kategórie" - tags: - label: "Štítky" - llm_triage: - fields: - category: - label: "Kategória" - tags: - label: "Štítky" - canned_reply: - label: "Odpoveď" - discourse_ai: - features: - back: "Späť" - disabled: "(vypnuté)" - groups: "Skupiny:" - no_persona: "Nie je nastavené" - no_groups: "Žiadny" - edit: "Upraviť" - expand_list: - one: "(%{count} ďalší)" - few: "(%{count} ďalšie)" - many: "(%{count} ďalších)" - other: "(%{count} ďalší)" - collapse_list: "(zobraziť menej)" - filters: - all: "Všetky" - reset: "Resetovať" - search: - name: "Hľadať" - spam: - name: "Spam" - modals: - select_option: "Vyberte možnosť..." - spam: - short_title: "Spam" - last_seven_days: "Posledných 7 dní" - enable: "Povoliť" - test_modal: - result: "Výsledok" - spam: "Spam" - usage: - summary: "Zhrnutie" - username: "Používateľské meno" - total_requests: "celkový počet žiadostí" - periods: - last_day: "Posledných 24 hodín" - custom: "Vlastné..." - ai_persona: - back: "Späť" - name: "Meno" - edit: "Upraviť" - export: "Export" - description: "Popis" - user: Používateľ - save: "Uložiť" - enabled: "Povolené?" - delete: "Odstrániť" - response_format: - open_modal: "Upraviť" - modal: - key_title: "Kľúč" - filters: - reset: "Resetovať" - rag: - uploads: - title: "Nahrávanie" - uploading: "Načítavam..." - tools: - back: "Späť" - export: "Export" - name: "Meno" - description: "Popis" - summary: "Zhrnutie" - script: "Skript" - save: "Uložiť" - remove_parameter: "Odstrániť" - parameter_required: "Povinné" - edit: "Upraviť" - delete: "Odstrániť" - llms: - display_name: "Meno" - save: "Uložiť" - edit: "Upraviť" - back: "Späť" - delete: Odstrániť - quotas: - group: "Skupina" - max_usages: "Maximálne využitie" - duration: "Trvanie" - durations: - hour: "1 hodina" - six_hours: "6 hodiny" - day: "24 hodín" - week: "7 dní" - custom: "Vlastné..." - hours: "hodiny" - usage: - ai_summarization: "Zhrnúť" - ai_spam: "Spam" - next: - title: "Ďalej" - tests: - success: "Úspech!" - providers: - google: "Google" - fake: "Vlastné" - ai_helper: - context_menu: - cancel: "Zrušiť" - confirm: "Potvrďte" - discard: "Zahodiť" - post_options_menu: - close: "Zavrieť" - copy: "Kopírovať" - copied: "Skopírované!" - cancel: "Zrušiť" - thumbnail_suggestions: - select: "Vyberte" - selected: "Vybrané" - image_caption: - save_caption: "Uložiť" - automatic_caption_dialog: - confirm: "Povoliť" - embeddings: - back: "Späť" - save: "Uložiť" - delete: "Odstrániť" - edit: "Upraviť" - tests: - title: "Spustiť test" - success: "Úspech!" - display_name: "Meno" - providers: - google: "Google" - fake: "Vlastné" - ai_bot: - debug_ai_modal: - request: "Požiadavka" - response: "Odpoveď" - next_log: "Ďalej" - previous_log: "Predchádzajúci" - invite_ai_conversation: - button: "Pozvi" - share_modal: - copy: "Kopírovať" - conversations: - today: "Dnes" - last_7_days: "Posledných 7 dní" - last_30_days: "Posledných 30 dní" - sentiments: - sentiment_analysis: - filter_types: - all: "Všetky" - neutral: "Neutrálna" - group_types: - category: "Kategória" - tag: "Značka" - table: - total_count: "Celkovo" - summarization: - chat: - title: "Zhrnutie správ" - description: "Ak chcete zhrnúť konverzáciu odoslanú počas požadovaného časového obdobia, vyberte nižšie uvedenú možnosť." - summarize: "Zhrnúť" - since: - one: "Posledná hodina" - few: "Posledných %{count} hodín" - many: "Posledných %{count} hodín" - other: "Posledných %{count} hodín" - discobot_discoveries: - regular_results: "Témy" - collapse: "Zbaliť" - tooltip: - actions: - disable: "Zakázať" diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml deleted file mode 100644 index 8d0b181e..00000000 --- a/config/locales/client.sl.yml +++ /dev/null @@ -1,177 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sl: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Uredi po" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID teme" - title: - label: "Naziv" - categories: - label: "Kategorije" - tags: - label: "Oznake" - llm_triage: - fields: - category: - label: "Kategorija" - tags: - label: "Oznake" - canned_reply: - label: "Odgovori" - discourse_ai: - features: - back: "Nazaj" - disabled: "(onemogočeno)" - groups: "Skupine:" - no_persona: "Ni nastavljeno" - no_groups: "Brez" - edit: "Uredi" - expand_list: - one: "(%{count} več)" - two: "(%{count} več)" - few: "(%{count} več)" - other: "(%{count} več)" - collapse_list: "(manj)" - filters: - all: "Vse" - reset: "Ponastavi" - search: - name: "Išči" - spam: - name: "Neželeno" - modals: - select_option: "Izberi možnost..." - spam: - short_title: "Neželeno" - last_seven_days: "Zadnjih 7 dni" - enable: "Omogoči" - test_modal: - spam: "Neželeno" - usage: - summary: "Povzetek" - username: "Uporabniško ime" - periods: - last_day: "Zadnjih 24 ur" - custom: "Po meri..." - ai_persona: - back: "Nazaj" - name: "Ime" - edit: "Uredi" - export: "Izvozi" - description: "Opis" - user: Uporabnik - save: "Shrani" - enabled: "Vključeno?" - allowed_groups: "Dovoljene skupine" - delete: "Izbriši" - response_format: - open_modal: "Uredi" - filters: - reset: "Ponastavi" - rag: - uploads: - title: "Prenosi" - uploading: "Nalagam..." - tools: - back: "Nazaj" - export: "Izvozi" - name: "Ime" - description: "Opis" - summary: "Povzetek" - save: "Shrani" - remove_parameter: "Odstrani" - parameter_required: "Zahtevano" - edit: "Uredi" - delete: "Izbriši" - llms: - display_name: "Ime" - save: "Shrani" - edit: "Uredi" - back: "Nazaj" - delete: Izbriši - quotas: - group: "Skupina" - duration: "Trajanje" - durations: - hour: "1 ura" - six_hours: "6 uri" - day: "24 uri" - week: "7 dni" - custom: "Po meri..." - hours: "ur" - usage: - ai_spam: "Neželeno" - next: - title: "Naprej" - tests: - success: "Uspeh!" - providers: - google: "Google" - fake: "Po meri" - ai_helper: - context_menu: - cancel: "Prekliči" - confirm: "Potrdi" - discard: "Zavrzi" - post_options_menu: - close: "Zapri" - copy: "Kopiraj" - copied: "Kopirano!" - cancel: "Prekliči" - image_caption: - save_caption: "Shrani" - automatic_caption_dialog: - confirm: "Omogoči" - embeddings: - back: "Nazaj" - save: "Shrani" - delete: "Izbriši" - edit: "Uredi" - tests: - success: "Uspeh!" - display_name: "Ime" - providers: - google: "Google" - fake: "Po meri" - ai_bot: - debug_ai_modal: - request: "Zahteva" - next_log: "Naprej" - previous_log: "Prejšnja" - invite_ai_conversation: - button: "Povabi" - share_modal: - copy: "Kopiraj" - conversations: - today: "Danes" - last_7_days: "Zadnjih 7 dni" - last_30_days: "Zadnjih 30 dni" - sentiments: - sentiment_analysis: - filter_types: - all: "Vse" - group_types: - category: "Kategorija" - table: - total_count: "Skupaj" - discobot_discoveries: - regular_results: "Tem" - collapse: "Skrči" - tooltip: - actions: - disable: "Onemogoči" diff --git a/config/locales/client.sq.yml b/config/locales/client.sq.yml deleted file mode 100644 index 1bb3d48c..00000000 --- a/config/locales/client.sq.yml +++ /dev/null @@ -1,161 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sq: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Rendit sipas" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID e temës" - title: - label: "Titulli" - categories: - label: "Categories" - tags: - label: "Etiketat" - llm_triage: - fields: - category: - label: "Kategori" - tags: - label: "Etiketat" - canned_reply: - label: "Përgjigju" - discourse_ai: - features: - back: "Kthehu mbrapa" - disabled: "(e çaktivizuar)" - groups: "Grupet:" - no_persona: "Nuk është vendosur" - no_groups: "Asnjë" - edit: "Redaktoni" - expand_list: - one: "(%{count} tjetër)" - other: "(%{count} të tjera)" - collapse_list: "(shfaq më pak)" - filters: - all: "Të Gjithë" - reset: "Rivendosni" - search: - name: "Kërko" - spam: - name: "Spam" - modals: - select_option: "Zgjidhni një opsion..." - spam: - short_title: "Spam" - enable: "Aktivizo" - test_modal: - spam: "Spam" - usage: - summary: "Përmbledhja" - username: "Emri i përdoruesit" - total_requests: "Total requests" - ai_persona: - back: "Kthehu mbrapa" - name: "Emri" - edit: "Redakto" - export: "Eksporto" - description: "Përshkrimi" - user: User - save: "Ruaj" - enabled: "Aktivizuar?" - delete: "Fshij" - response_format: - open_modal: "Redaktoni" - filters: - reset: "Rivendosni" - rag: - uploads: - uploading: "Duke ngarkuar..." - tools: - back: "Kthehu mbrapa" - export: "Eksporto" - name: "Emri" - description: "Përshkrimi" - summary: "Përmbledhja" - save: "Ruaj" - remove_parameter: "Hiq" - parameter_required: "E nevojshme" - edit: "Redakto" - delete: "Fshij" - llms: - display_name: "Emri" - save: "Ruaj" - edit: "Redakto" - back: "Kthehu mbrapa" - delete: Fshij - quotas: - group: "Grupi" - max_usages: "Përdorimet maksimale" - duration: "Kohëzgjatja" - durations: - hour: "1 orë" - six_hours: "6 orë" - day: "24 orë" - week: "7 ditë" - custom: "Me porosi..." - hours: "orë" - usage: - ai_spam: "Spam" - next: - title: "Vazhdo përpara" - providers: - google: "Google" - fake: "Grupet e krijuara" - ai_helper: - context_menu: - cancel: "Anulo" - post_options_menu: - close: "Mbyll" - copy: "Kopjo" - cancel: "Anulo" - image_caption: - save_caption: "Ruaj" - automatic_caption_dialog: - confirm: "Aktivizo" - embeddings: - back: "Kthehu mbrapa" - save: "Ruani" - delete: "Fshij" - edit: "Redaktoni" - display_name: "Emri" - providers: - google: "Google" - fake: "Grupet e krijuara" - ai_bot: - debug_ai_modal: - next_log: "Vazhdo përpara" - previous_log: "I kaluar" - invite_ai_conversation: - button: "Fto" - share_modal: - copy: "Kopjo" - conversations: - today: "Sot" - sentiments: - sentiment_analysis: - filter_types: - all: "Të Gjithë" - group_types: - category: "Kategori" - table: - total_count: "Total" - discobot_discoveries: - regular_results: "Topics" - collapse: "Zvogëloni" - tooltip: - actions: - disable: "Çaktivizoni" diff --git a/config/locales/client.sr.yml b/config/locales/client.sr.yml deleted file mode 100644 index 7e8441d8..00000000 --- a/config/locales/client.sr.yml +++ /dev/null @@ -1,155 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sr: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Sortiraj po" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID Teme" - title: - label: "Naslov" - categories: - label: "Kategorije" - llm_triage: - fields: - category: - label: "Kategorija" - canned_reply: - label: "Odgovori" - discourse_ai: - features: - back: "Nazad" - disabled: "(isključeno)" - groups: "Grupe:" - no_groups: "Ništa" - edit: "Izmeni" - expand_list: - one: "(još %{count} članova)" - few: "(još %{count} člana)" - other: "(još %{count} članova)" - collapse_list: "(pokaži manje)" - filters: - all: "sve" - reset: "Reset" - search: - name: "Pretraži" - spam: - name: "Nepoželjno" - modals: - select_option: "Izaberi jednu od opcija..." - spam: - short_title: "Nepoželjno" - last_seven_days: "Последњих 7 дана" - enable: "Omogući" - test_modal: - spam: "Nepoželjno" - usage: - summary: "Rezime" - username: "Korisničko Ime" - periods: - last_day: "Последња 24 сата" - ai_persona: - back: "Nazad" - name: "Ime foruma" - edit: "Izmeni" - export: "Izvoz" - description: "Opis" - user: Korisnik - save: "Sačuvaj" - enabled: "Omgućeno" - allowed_groups: "Dozvoljene grupe" - delete: "Obriši" - response_format: - open_modal: "Izmeni" - filters: - reset: "Reset" - rag: - uploads: - uploading: "Uploading..." - tools: - back: "Nazad" - export: "Izvoz" - name: "Ime foruma" - description: "Opis" - summary: "Rezime" - save: "Sačuvaj" - remove_parameter: "Ukloni" - parameter_required: "Potrebno" - edit: "Izmeni" - delete: "Obriši" - llms: - display_name: "Ime foruma" - save: "Sačuvaj" - edit: "Izmeni" - back: "Nazad" - delete: Obriši - quotas: - group: "Grupa" - usage: - ai_spam: "Nepoželjno" - next: - title: "Dalje" - providers: - google: "Google" - fake: "Posebna" - ai_helper: - context_menu: - cancel: "Odustani" - discard: "Odbaci" - post_options_menu: - close: "Zatvori" - copy: "Kopija" - copied: "Kopirano!" - cancel: "Odustani" - image_caption: - save_caption: "Sačuvaj" - automatic_caption_dialog: - confirm: "Omogući" - embeddings: - back: "Nazad" - save: "Sačuvaj" - delete: "Obriši" - edit: "Izmeni" - display_name: "Ime foruma" - providers: - google: "Google" - fake: "Posebna" - ai_bot: - debug_ai_modal: - next_log: "Dalje" - previous_log: "Prethodno" - invite_ai_conversation: - button: "Pozovite" - share_modal: - copy: "Kopija" - conversations: - today: "Danas" - last_7_days: "Последњих 7 дана" - last_30_days: "Последњих 30 дана" - sentiments: - sentiment_analysis: - filter_types: - all: "sve" - group_types: - category: "Kategorija" - table: - total_count: "Ukupno" - discobot_discoveries: - regular_results: "Teme" - collapse: "Spusti" - tooltip: - actions: - disable: "Onemogući" diff --git a/config/locales/client.sv.yml b/config/locales/client.sv.yml deleted file mode 100644 index 4b60621a..00000000 --- a/config/locales/client.sv.yml +++ /dev/null @@ -1,198 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sv: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Sortera efter" - tag: - label: "Tagg" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Ämnes-ID" - title: - label: "Rubrik" - categories: - label: "Kategorier" - tags: - label: "Taggar" - llm_triage: - fields: - category: - label: "Kategori" - tags: - label: "Taggar" - canned_reply: - label: "Svara" - discourse_ai: - features: - back: "Tillbaka" - disabled: "(inaktiverad)" - groups: "Grupper:" - no_persona: "Inte inställd" - no_groups: "Ingen" - edit: "Redigera" - expand_list: - one: "(ytterligare %{count})" - other: "(ytterligare %{count})" - collapse_list: "(visa mindre)" - filters: - all: "Alla" - reset: "Återställ" - search: - name: "Sök" - spam: - name: "Skräppost" - modals: - select_option: "Välj ett alternativ..." - spam: - short_title: "Skräppost" - last_seven_days: "Senaste 7 dagarna" - enable: "Aktivera" - test_modal: - spam: "Skräppost" - usage: - summary: "Sammanfattning" - username: "Användarnamn" - total_requests: "Totalt antal efterfrågningar" - periods: - last_day: "Senaste 24 timmarna" - custom: "Anpassad..." - ai_persona: - back: "Tillbaka" - name: "Namn" - edit: "Redigera" - export: "Exportera" - description: "Beskrivning" - user: Användare - save: "Spara" - enabled: "Aktiverad?" - allowed_groups: "Tillåtna grupper" - delete: "Radera" - response_format: - open_modal: "Redigera" - modal: - key_title: "Key" - filters: - reset: "Återställ" - rag: - uploads: - title: "Uppladdningar" - uploading: "Laddar upp..." - tools: - back: "Tillbaka" - export: "Exportera" - name: "Namn" - description: "Beskrivning" - summary: "Sammanfattning" - save: "Spara" - remove_parameter: "Ta bort" - parameter_required: "Krävs" - edit: "Redigera" - delete: "Ta bort" - llms: - display_name: "Namn" - save: "Spara" - edit: "Redigera" - back: "Tillbaka" - delete: Radera - quotas: - group: "Grupp" - max_usages: "Max antal användningar" - duration: "Varaktighet" - durations: - hour: "1 timme" - six_hours: "6 timmar" - day: "24 timmar" - week: "7 dagar" - custom: "Anpassad..." - hours: "timmar" - usage: - ai_summarization: "Sammanfatta" - ai_spam: "Skräppost" - next: - title: "Nästa" - tests: - success: "Lyckades!" - providers: - google: "Google" - fake: "Anpassad" - ai_helper: - context_menu: - cancel: "Avbryt" - confirm: "Bekräfta" - discard: "Förkasta" - post_options_menu: - close: "Stäng" - copy: "Kopiera" - copied: "Kopierad!" - cancel: "Avbryt" - thumbnail_suggestions: - select: "Välj" - selected: "Markerat" - image_caption: - save_caption: "Spara" - automatic_caption_dialog: - confirm: "Aktivera" - embeddings: - back: "Tillbaka" - save: "Spara" - delete: "Ta bort" - edit: "Redigera" - tests: - success: "Lyckades!" - display_name: "Namn" - providers: - google: "Google" - fake: "Anpassad" - ai_bot: - debug_ai_modal: - request: "Förfrågning" - response: "Svar" - next_log: "Nästa" - previous_log: "Föregående" - invite_ai_conversation: - button: "Bjud in" - share_modal: - copy: "Kopiera" - conversations: - today: "Idag" - last_7_days: "Senaste 7 dagarna" - last_30_days: "Senaste 30 dagarna" - sentiments: - sentiment_analysis: - filter_types: - all: "Alla" - neutral: "Neutralt" - group_types: - category: "Kategori" - tag: "Tagg" - table: - total_count: "Totalt" - summarization: - chat: - title: "Sammanfatta meddelanden" - description: "Välj ett alternativ nedan för att sammanfatta samtalet som skickades under den önskade tidsramen." - summarize: "Sammanfatta" - since: - one: "Senaste timmen" - other: "Senaste %{count} timmarna" - topic: - title: "Sammanfattning av ämne" - discobot_discoveries: - regular_results: "Ämnen" - collapse: "Förminska" - tooltip: - actions: - disable: "Inaktivera" diff --git a/config/locales/client.sw.yml b/config/locales/client.sw.yml deleted file mode 100644 index 52db43f0..00000000 --- a/config/locales/client.sw.yml +++ /dev/null @@ -1,154 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sw: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Panga kwa" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "Utambulisho wa Mada" - title: - label: "Kichwa cha Habari" - categories: - label: "Kategoria" - tags: - label: "Lebo" - llm_triage: - fields: - category: - label: "Kategoria" - tags: - label: "Lebo" - canned_reply: - label: "Jibu" - discourse_ai: - features: - back: "Iliyopita" - disabled: "(imezuiwa)" - groups: "Makundi:" - no_groups: "Hakuna" - edit: "Hariri" - filters: - all: "Vyote" - reset: "Anza Upya" - search: - name: "Tafuta" - spam: - name: "Barua Taka" - modals: - select_option: "Chagua chaguo moja..." - spam: - short_title: "Barua Taka" - enable: "Wezesha" - test_modal: - spam: "Barua Taka" - usage: - summary: "Muhtasari" - username: "Jina la mtumiaji" - total_requests: "Jumla ya Maombi" - ai_persona: - back: "Iliyopita" - name: "Jina" - edit: "Hariri" - export: "Hamisha" - description: "Elezo" - user: Mtumiaji - save: "Hifadhi" - enabled: "Imeruhusiwa?" - delete: "Futa" - response_format: - open_modal: "Hariri" - filters: - reset: "Anza Upya" - rag: - uploads: - title: "Upakiaji" - uploading: "Inaongezwa" - tools: - back: "Iliyopita" - export: "Hamisha" - name: "Jina" - description: "Elezo" - summary: "Muhtasari" - save: "Hifadhi" - remove_parameter: "Ondoa" - parameter_required: "Muhimu na Inahitajika" - edit: "Hariri" - delete: "Futa" - llms: - display_name: "Jina" - save: "Hifadhi" - edit: "Hariri" - back: "Iliyopita" - delete: Futa - quotas: - group: "Kikundi" - usage: - ai_spam: "Barua Taka" - next: - title: "Ijayo" - tests: - success: "Mafanikio!" - providers: - google: "Google" - fake: "Binafsi" - ai_helper: - context_menu: - cancel: "Ghairi" - post_options_menu: - close: "Funga" - copy: "Nakili" - cancel: "Ghairi" - image_caption: - save_caption: "Hifadhi" - automatic_caption_dialog: - confirm: "Wezesha" - embeddings: - back: "Iliyopita" - save: "Hifadhi" - delete: "Futa" - edit: "Hariri" - tests: - success: "Mafanikio!" - display_name: "Jina" - providers: - google: "Google" - fake: "Binafsi" - ai_bot: - debug_ai_modal: - request: "Ombi" - response: "Mwitikio" - next_log: "Ijayo" - previous_log: "Uliopita" - invite_ai_conversation: - button: "Mualiko" - share_modal: - copy: "Nakili" - conversations: - today: "Leo" - sentiments: - sentiment_analysis: - filter_types: - all: "Vyote" - group_types: - category: "Kategoria" - table: - total_count: "Jumla" - discobot_discoveries: - regular_results: "Mada" - collapse: "Kunja" - tooltip: - actions: - disable: "Sitisha" diff --git a/config/locales/client.te.yml b/config/locales/client.te.yml deleted file mode 100644 index 714601e4..00000000 --- a/config/locales/client.te.yml +++ /dev/null @@ -1,170 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -te: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "క్రమబద్ధీకరించండి" - tag: - label: "ట్యాగ్" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "విషయపు ఐడీ" - title: - label: "శీర్షిక" - categories: - label: "వర్గాలు" - tags: - label: "ట్యాగులు" - llm_triage: - fields: - category: - label: "వర్గం" - tags: - label: "ట్యాగులు" - canned_reply: - label: "జవాబు" - discourse_ai: - features: - back: "వెనుకకు" - no_persona: "సెట్ చేయబడలేదు" - no_groups: "ఏదీ లేదు" - edit: "సవరించండి" - expand_list: - one: "(మరో %{count})" - other: "(మరో %{count})" - filters: - all: "అన్ని" - reset: "రీసెట్ చేయండి" - search: - name: "శోధించండి" - spam: - name: "స్పామ్" - spam: - short_title: "స్పామ్" - enable: "ప్రారంభించండి" - test_modal: - spam: "స్పామ్" - usage: - summary: "సారాంశం" - username: "సభ్యనామం" - total_requests: "మొత్తం అభ్యర్థనలు" - ai_persona: - back: "వెనుకకు" - name: "పేరు" - edit: "సవరించండి" - export: "ఎగుమతి" - description: "వివరణ" - user: సభ్యుడు - save: "భద్రపరుచు" - enabled: "ప్రారంభించబడిందా?" - delete: "తొలగించు" - response_format: - open_modal: "సవరించండి" - modal: - key_title: "కీ" - filters: - reset: "రీసెట్ చేయండి" - rag: - uploads: - title: "అప్‌లోడ్‌లు" - uploading: "ఎగుమతవుతోంది..." - tools: - back: "వెనుకకు" - export: "ఎగుమతి" - name: "పేరు" - description: "వివరణ" - summary: "సారాంశం" - save: "సేవ్ చేయండి" - remove_parameter: "తొలగించు" - parameter_required: "అవసరం" - edit: "సవరించండి" - delete: "తొలగించండి" - llms: - display_name: "పేరు" - save: "సేవ్ చేయండి" - edit: "సవరించండి" - back: "వెనుకకు" - delete: తొలగించండి - quotas: - group: "సమూహం" - max_usages: "గరిష్ట ఉపయోగాలు" - duration: "వ్యవధి" - durations: - hour: "1 గంట" - six_hours: "6 గంటలు" - day: "24 గంటలు" - week: "7 రోజులు" - hours: "గంటలు" - usage: - ai_spam: "స్పామ్" - next: - title: "తదుపరి" - providers: - google: "గూగుల్" - fake: "అనుకూల" - ai_helper: - context_menu: - cancel: "రద్దుచేయి" - confirm: "నిర్ధారించండి" - discard: "విస్మరించండి" - post_options_menu: - close: "మూసివేయి" - copy: "నకలు" - copied: "కాపీ చేయబడింది!" - cancel: "రద్దుచేయి" - thumbnail_suggestions: - select: "ఎంచుకోండి" - selected: "ఎంపిక చేయబడినవి" - image_caption: - save_caption: "భద్రపరుచు" - automatic_caption_dialog: - confirm: "ప్రారంభించండి" - embeddings: - back: "వెనుకకు" - save: "సేవ్ చేయండి" - delete: "తొలగించండి" - edit: "సవరించండి" - display_name: "పేరు" - providers: - google: "గూగుల్" - fake: "అనుకూల" - ai_bot: - debug_ai_modal: - request: "అభ్యర్థన" - response: "ప్రతిస్పందన" - next_log: "తదుపరి" - previous_log: "గత" - invite_ai_conversation: - button: "ఆహ్వానించండి" - share_modal: - copy: "నకలు" - conversations: - today: "ఈరోజు" - sentiments: - sentiment_analysis: - filter_types: - all: "అన్ని" - neutral: "తటస్థ" - group_types: - category: "వర్గం" - tag: "ట్యాగ్" - table: - total_count: "మొత్తం" - discobot_discoveries: - regular_results: "విషయాలు" - collapse: "కుదించండి" - tooltip: - actions: - disable: "నిలిపివేయండి" diff --git a/config/locales/client.th.yml b/config/locales/client.th.yml deleted file mode 100644 index a7b490e9..00000000 --- a/config/locales/client.th.yml +++ /dev/null @@ -1,156 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -th: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "เรียงโดย" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "หมายเลขกระทู้" - title: - label: "ชื่อเรื่อง" - categories: - label: "หมวดหมู่" - tags: - label: "ป้าย" - llm_triage: - fields: - category: - label: "หมวดหมู่" - tags: - label: "ป้าย" - canned_reply: - label: "ตอบ" - discourse_ai: - features: - back: "กลับ" - disabled: "(ปิดใช้งานแล้ว)" - groups: "กลุ่ม:" - no_groups: "ไม่มี" - edit: "แก้ไข" - expand_list: - other: "(อีก %{count})" - filters: - all: "ทั้งหมด" - search: - name: "ค้นหา" - spam: - name: "ขยะ" - modals: - select_option: "เลือกตัวเลือก..." - spam: - short_title: "ขยะ" - last_seven_days: "7 วันที่ผ่านมา" - enable: "เปิดใช้งาน" - test_modal: - spam: "ขยะ" - usage: - summary: "สรุป" - username: "ชื่อผู้ใช้" - periods: - last_day: "24 ชั่วโมงที่ผ่านมา" - ai_persona: - back: "กลับ" - name: "ชื่อ" - edit: "แก้ไข" - export: "ส่งออก" - description: "รายละเอียด" - user: ผู้ใช้ - save: "บันทึก" - enabled: "เปิดใช้" - delete: "ลบ" - response_format: - open_modal: "แก้ไข" - rag: - uploads: - title: "อัปโหลด" - uploading: "กำลังอัปโหลด..." - tools: - back: "กลับ" - export: "ส่งออก" - name: "ชื่อ" - description: "รายละเอียด" - summary: "สรุป" - save: "บันทึก" - remove_parameter: "ลบ" - parameter_required: "ต้องการ" - edit: "แก้ไข" - delete: "ลบ" - llms: - display_name: "ชื่อ" - save: "บันทึก" - edit: "แก้ไข" - back: "กลับ" - delete: ลบ - quotas: - group: "กลุ่ม" - duration: "ช่วงเวลา" - usage: - ai_spam: "ขยะ" - next: - title: "ต่อไป" - tests: - success: "สำเร็จ!" - providers: - google: "กูเกิล" - ai_helper: - context_menu: - cancel: "ยกเลิก" - post_options_menu: - close: "ปิด" - copy: "คัดลอก" - copied: "คัดลอกแล้ว!" - cancel: "ยกเลิก" - image_caption: - save_caption: "บันทึก" - automatic_caption_dialog: - confirm: "เปิดใช้งาน" - embeddings: - back: "กลับ" - save: "บันทึก" - delete: "ลบ" - edit: "แก้ไข" - tests: - success: "สำเร็จ!" - display_name: "ชื่อ" - providers: - google: "กูเกิล" - ai_bot: - debug_ai_modal: - request: "ร้องขอ" - next_log: "ต่อไป" - previous_log: "ก่อนหน้า" - invite_ai_conversation: - button: "เชิญ" - share_modal: - copy: "คัดลอก" - conversations: - today: "วันนี้" - last_7_days: "7 วันที่ผ่านมา" - last_30_days: "30 วันที่ผ่านมา" - sentiments: - sentiment_analysis: - filter_types: - all: "ทั้งหมด" - group_types: - category: "หมวดหมู่" - table: - total_count: "ทั้งหมด" - discobot_discoveries: - regular_results: "หัวข้อ" - collapse: "ย่อ" - tooltip: - actions: - disable: "ปิดใช้งาน" diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml deleted file mode 100644 index 6cdf1757..00000000 --- a/config/locales/client.tr_TR.yml +++ /dev/null @@ -1,687 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -tr_TR: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "YZ aramasına izin verir" - stream_completion: "YZ kişilik tamamlamalarının yayınlanmasına izin verir" - site_settings: - categories: - discourse_ai: "Discourse YZ" - dashboard: - emotion: - title: "Duygu" - description: "Tablo, belirli bir duyguyla sınıflandırılan gönderilerin sayısını listeler. \"SamLowe/roberta-base-go_emotions\" modeliyle sınıflandırılmıştır." - reports: - filters: - sort_by: - label: "Sıralama ölçütü" - tag: - label: "Etiket" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Gönderen" - description: "Raporu gönderecek kullanıcı" - receivers: - label: "Alıcılar" - description: "Raporu alacak kullanıcılar (e-postalara doğrudan e-posta gönderilecek, kullanıcı adlarına kişisel mesaj gönderilecek)" - topic_id: - label: "Konu kimliği" - description: "Raporun gönderileceği konu kimliği" - title: - label: "Başlık" - description: "Raporun başlığı" - days: - label: "Gün" - description: "Raporun zaman aralığı" - offset: - label: "Ofset" - description: "Test yaparken raporu tarihsel olarak çalıştırmak isteyebilirsiniz, raporu daha önceki bir tarihte başlatmak için ofset kullanın" - instructions: - label: "Talimatlar" - description: "Büyük dil modeline sağlanan talimatlar" - sample_size: - label: "Örnek Boyutu" - description: "Rapor için örneklenecek gönderi sayısı" - tokens_per_post: - label: "Gönderi başına token" - description: "Gönderi başına kullanılacak LLM token'ı sayısı" - model: - label: "Model" - description: "Rapor oluşturmak için kullanılacak LLM" - categories: - label: "Kategoriler" - description: "Konuları yalnızca bu kategorilere filtrele" - tags: - label: "Etiketler" - description: "Konuları yalnızca bu etiketlere filtrele" - exclude_tags: - label: "Etiketleri Hariç Tut" - description: "Bu etiketlere sahip konuları hariç tut" - exclude_categories: - label: "Kategorileri Dışla" - description: "Bu kategorilere sahip konuları hariç tut" - allow_secure_categories: - label: "Güvenli kategorilere izin ver" - description: "Güvenli kategorilerdeki konular için rapor oluşturulmasına izin ver" - suppress_notifications: - label: "Bildirimleri bastır" - description: "Raporun içeriğe dönüştürerek oluşturabileceği bildirimleri bastırın. Bu, bahsedenleri ve dâhili bağlantıları yeniden eşleştirir." - debug_mode: - label: "Hata Ayıklama Modu" - description: "LLM'nin ham giriş ve çıkışını görmek için hata ayıklama modunu etkinleştirin" - priority_group: - label: "Öncelikli Grup" - description: "Raporda bu gruptan gelen içeriği önceliklendirin" - temperature: - label: "Sıcaklık" - top_p: - label: "Üst P" - llm_tool_triage: - fields: - model: - label: "Model" - llm_triage: - fields: - system_prompt: - label: "Sistem İstemi" - description: "Triyaj için kullanılacak istem, eylemi tetiklemek için kullanabileceğiniz tek bir kelime ile yanıt verdiğinden emin olun" - max_post_tokens: - label: "Maksimum Gönderi Token'ı Sayısı" - description: "LLM triyajı kullanılarak taranacak maksimum token sayısı" - stop_sequences: - label: "Durdurma Dizileri" - description: "Modele, bu değerlerden birine ulaşıldığında token oluşturmayı durdurması talimatını verin" - search_for_text: - label: "Metin ara" - description: "LLM yanıtında aşağıdaki metin görünürse bu eylemleri uygulayın" - category: - label: "Kategori" - description: "Konuya uygulanacak kategori" - tags: - label: "Etiketler" - description: "Konuya uygulanacak etiketler" - canned_reply: - label: "Yanıtla" - description: "Konuyla ilgili gönderiye verilen hazır yanıtın ham metni" - canned_reply_user: - label: "Kullanıcıyı yanıtla" - description: "Hazır yanıtı gönderecek kullanıcının kullanıcı adı" - hide_topic: - label: "Konuyu gizle" - description: "Tetiklenirse konuyu herkese görünmez hâle getirin" - flag_type: - label: "Bayrak türü" - description: "Gönderiye uygulanacak bayrak türü (spam veya sadece inceleme için ekleyin)" - flag_post: - label: "Gönderiye bayrak ekle" - description: "Gönderiye bayrak ekler (spam olarak veya inceleme için)" - include_personal_messages: - label: "Kişisel mesaj dâhil edin" - description: "Ayrıca kişisel mesajları tarayın ve önceliklendirin" - model: - label: "Model" - description: "Triyaj için kullanılan dil modeli" - temperature: - label: "Sıcaklık" - discourse_ai: - title: "YZ" - features: - back: "Geri" - disabled: "(devre Dışı)" - groups: "Grup:" - no_persona: "Ayarlanmamış" - no_groups: "Yok" - edit: "Düzenle" - expand_list: - one: "(%{count} tane Daha)" - other: "(%{count} tane Daha)" - collapse_list: "(daha az göster)" - filters: - all: "Hepsi" - reset: "Sıfırla" - search: - name: "Ara" - embeddings: - name: "Gömmeler" - ai_helper: - name: "Yardımcı" - proofread: Yazım hataları düzeltilmiş metin - explain: "Açıkla" - smart_dates: "Akıllı tarihler" - markdown_tables: "Markdown tablosu oluştur" - custom_prompt: "Özel istem" - spam: - name: "İstenmeyen içerik" - description: "Seçili LLM'i kullanarak potansiyel istenmeyen içerikleri belirler ve inceleme kuyruğunda site moderatörlerinin incelemesi için işaretler" - modals: - select_option: "Seçenek belirleyin..." - spam: - short_title: "İstenmeyen içerik" - title: "İstenmeyen içerik işlemeyi yapılandırın" - select_llm: "LLM seç" - custom_instructions: "Özel talimatlar" - custom_instructions_help: "YZ'nin istenmeyen içerikleri tespit etmesine yardımcı olmak için sitenize özel talimatlar, ör. \"İngilizce olmayan gönderileri tarama konusunda daha agresif ol\"." - last_seven_days: "Son 7 gün" - scanned_count: "Taranan gönderiler" - false_positives: "Yanlış bayrak eklenmiş" - false_negatives: "Kaçırılan istenmeyen içerik" - spam_detected: "İstenmeyen içerik algılandı" - custom_instructions_placeholder: "YZ'nin istenmeyen içerikleri daha doğru bir şekilde belirlemesine yardımcı olmak için siteye özel talimatlar" - enable: "Etkinleştir" - spam_tip: "YZ istenmeyen içerik tespiti, herkese açık konulardaki tüm yeni kullanıcıların ilk 3 gönderisini tarar. Bunları inceleme için işaretler ve spam olma ihtimalleri varsa kullanıcıları engeller." - settings_saved: "Ayarlar kaydedildi" - spam_description: "Seçili LLM'i kullanarak potansiyel istenmeyen içerikleri belirler ve inceleme kuyruğunda site moderatörlerinin incelemesi için işaretler" - no_llms: "LLM mevcut değil" - test_button: "Test..." - save_button: "Değişiklikleri kaydet" - test_modal: - title: "İstenmeyen içerik algılamayı test edin" - post_url_label: "Gönderi URL'si veya kimliği" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 veya gönderi kimliği" - result: "Sonuç" - scan_log: "Tarama günlüğü" - run: "Testi çalıştır" - spam: "İstenmeyen içerik" - not_spam: "İstenmeyen içerik değil" - stat_tooltips: - incorrectly_flagged: "YZ botunun spam olarak bayrak eklediği ve moderatörlerin aynı fikirde olmadığı ögeler" - missed_spam: "YZ botu tarafından tespit edilemeyen ve moderatörlerin de onayladığı, topluluk tarafından istenmeyen içerik olarak bayrak eklenen ögeler" - errors: - scan_not_admin: - message: "Uyarı: İstenmeyen içerik tarama hesabı bir yönetici olmadığından istenmeyen içerik tarama düzgün çalışmaz" - action: "Düzelt" - resolved: "Hata çözüldü!" - usage: - short_title: "Kullanım" - summary: "Özet" - total_tokens: "Toplam token sayısı" - tokens_over_time: "Zaman içindeki token'lar" - features_breakdown: "Özellik başına kullanım" - feature: "Özellik" - usage_count: "Kullanım sayısı" - model: "Model" - models_breakdown: "Model başına kullanım" - users_breakdown: "Kullanıcı başına kullanım" - all_features: "Tüm özellikler" - all_models: "Tüm modeller" - username: "Kullanıcı Adı" - total_requests: "Toplam talep sayısı" - request_tokens: "Talep token'ları" - response_tokens: "Karşılık token'ları" - net_request_tokens: "Net talep token'ları" - cached_tokens: "Önbelleğe alınan token'lar" - cached_request_tokens: "Önbelleğe alınmış talep token'ları" - no_users: "Kullanıcı kullanım verisi bulunamadı" - no_models: "Model kullanım verisi bulunamadı" - no_features: "Özellik kullanım verisi bulunamadı" - subheader_description: "Token'lar, LLM'lerin metinleri anlamak ve üretmek için kullandıkları temel birimlerdir, kullanım verileri maliyetleri etkileyebilir" - stat_tooltips: - total_requests: "Discourse aracılığıyla LLM'lere yapılan tüm talepler" - total_tokens: "LLM istenirken kullanılan tüm token'lar" - request_tokens: "LLM'in söylediklerinizi anlamaya çalışırken kullandığı token'lar" - response_tokens: "LLM'in isteminize yanıt verdiğinde kullanılan token'lar" - cached_tokens: "LLM'in performansı ve maliyeti optimize etmek için yeniden kullandığı daha önce işlenmiş talep token'ları" - periods: - last_day: "Son 24 saat" - last_week: "Geçen hafta" - last_month: "Geçen ay" - custom: "Özel..." - ai_persona: - ai_tools: "Araçlar" - tool_strategies: - all: "Tüm yanıtlara uygula" - replies: - one: "Yalnızca ilk yanıta uygula" - other: "İlk %{count} yanıta uygula" - back: "Geri" - name: "Ad" - edit: "Düzenle" - export: "Dışa Aktar" - description: "Açıklama" - no_llm_selected: "Dil modeli seçilmedi" - max_context_posts: "Maksimum bağlam gönderisi sayısı" - max_context_posts_help: "Bir kullanıcıya yanıt verirken YZ için bağlam olarak kullanılacak maksimum gönderi sayısı. (varsayılan için boş)" - vision_enabled: Vizyon etkin - vision_enabled_help: Etkinleştirilirse YZ, kullanıcıların konuya gönderdiği görüntüleri anlamaya çalışır; bu, vizyonu destekleyen kullanılan modele bağlıdır. Anthropic, Google ve OpenAI'ın en son modelleri tarafından desteklenir. - vision_max_pixels: Desteklenen görüntü boyutu - vision_max_pixel_sizes: - low: Düşük kalite - en ucuz (256x256) - medium: Orta kalite (512x512) - high: Yüksek kalite - en yavaş (1024x1024) - tool_details: Araç ayrıntılarını göster - tool_details_help: Son kullanıcılara dil modelinin hangi araçları tetiklediğine ilişkin ayrıntıları gösterir. - mentionable: Bahsetmelere izin ver - mentionable_help: Etkinleştirilirse izin verilen gruplardaki kullanıcılar gönderilerde bu kullanıcıdan bahsedebilir, YZ bu kişi olarak yanıt verir. - user: Kullanıcı - create_user: Kullanıcı oluştur - create_user_help: İsteğe bağlı olarak bu kişiye bir kullanıcı ekleyebilirsiniz. Bunu yaparsanız YZ isteklere yanıt vermek için bu kullanıcıyı kullanır. - default_llm: Varsayılan dil modeli - default_llm_help: Bu kişi için kullanılacak varsayılan dil modeli. Herkese açık gönderilerde kişiden bahsetmek istiyorsanız gereklidir. - question_consolidator_llm: Soru Konsolidatörü için Dil Modeli - question_consolidator_llm_help: Soru birleştirici için kullanılacak dil modeli, maliyetten tasarruf etmek için daha az güçlü bir model seçebilirsiniz. - system_prompt: Sistem istemi - forced_tool_strategy: Zorlanmış araç stratejisi - allow_chat_direct_messages: "Doğrudan sohbet mesajlarına izin ver" - allow_chat_direct_messages_help: "Etkinleştirilirse izin verilen gruplardaki kullanıcılar bu kişiliğe doğrudan mesaj gönderebilir." - allow_chat_channel_mentions: "Sohbet kanalından bahsetmeye izin ver" - allow_chat_channel_mentions_help: "Etkinleştirilirse izin verilen gruplardaki kullanıcılar sohbet kanallarında bu kişilikten bahsedebilir." - allow_personal_messages: "Kişisel mesajlara izin ver" - allow_personal_messages_help: "Etkinleştirilirse izin verilen gruplardaki kullanıcılar bu kişiliğe kişisel mesaj gönderebilir." - allow_topic_mentions: "Konu bahsetmelerine izin ver" - allow_topic_mentions_help: "Etkinleştirilirse izin verilen gruplardaki kullanıcılar konularda bu kişilikten bahsedebilir." - force_default_llm: "Her zaman varsayılan dil modelini kullan" - save: "Kaydet" - saved: "Kişilik kaydedildi" - enabled: "Etkin mi?" - tools: "Etkinleştirilmiş araçlar" - forced_tools: "Zorlanmış araçlar" - allowed_groups: "İzin verilen gruplar" - confirm_delete: "Bu kişiliği silmek istediğinizden emin misiniz?" - new: "Yeni kişilik" - no_personas: "Henüz herhangi bir kişilik oluşturmadınız" - title: "Kişilikler" - short_title: "Kişiler" - delete: "Sil" - temperature: "Sıcaklık" - temperature_help: "LLM için kullanılacak sıcaklık. Yaratıcılığı artırmak için artırın (model varsayılanını kullanmak için boş bırakın, genellikle 0,0 ila 2,0 arasında bir değer)" - top_p: "Üst P" - top_p_help: "LLM için kullanılacak en yüksek P, rastgeleliği artırmak için artırın (model varsayılanını kullanmak için boş bırakın, genellikle 0,0 ila 1,0 arasında bir değer)" - priority: "Öncelik" - priority_help: "Öncelikli kişilikler kullanıcılara kişilik listesinin en üstünde gösterilir. Birden fazla kişiliğin önceliği varsa bunlar alfabetik olarak sıralanır." - tool_options: "Araç seçenekleri" - rag_conversation_chunks: "Konuşma parçalarını ara" - rag_conversation_chunks_help: "RAG modeli aramaları için kullanılacak parça sayısı. YZ'nin kullanabileceği bağlam miktarını artırmak için artırın." - persona_description: "Kişilikler, Discourse forumunuzdaki YZ motorunun davranışını özelleştirmenize olanak tanıyan güçlü bir özelliktir. YZ'nin yanıtlarını ve etkileşimlerini yönlendiren bir \"sistem mesajı\" olarak hareket ederek daha kişiselleştirilmiş ve ilgi çekici bir kullanıcı deneyimi oluşturmaya yardımcı olurlar." - response_format: - open_modal: "Düzenle" - modal: - key_title: "Anahtar" - filters: - reset: "Sıfırla" - rag: - options: - rag_chunk_tokens: "Parça token'ı yükle" - rag_chunk_tokens_help: "RAG modelindeki her parça için kullanılacak belirteç sayısı. YZ'nin kullanabileceği bağlam miktarını artırmak için artırın. (değiştirmek tüm yüklemeleri yeniden indeksler)" - rag_chunk_overlap_tokens: "Parça örtüşme token'ı yükle" - rag_chunk_overlap_tokens_help: "RAG modelinde parçalar arasında üst üste binecek token sayısı. (değiştirme tüm yüklemeleri yeniden indeksler)" - show_indexing_options: "Yükleme seçeneklerini göster" - hide_indexing_options: "Yükleme seçeneklerini gizle" - uploads: - title: "Yüklemeler" - button: "Dosya ekle" - filter: "Yüklemeleri filtrele" - indexed: "İndekslendi" - indexing: "İndeksleniyor" - uploaded: "İndekslenmeye hazır" - uploading: "Yükleniyor..." - remove: "Yüklemeyi kaldır" - tools: - back: "Geri" - short_title: "Araçlar" - export: "Dışa Aktar" - no_tools: "Henüz herhangi bir araç oluşturmadınız" - name: "Ad" - new: "Yeni araç" - description: "Açıklama" - description_help: "Dil modeli için aracın amacının net bir açıklaması" - subheader_description: "Araçlar, kullanıcı tanımlı JavaScript işlevleri ile YZ botlarının yeteneklerini genişletir." - summary: "Özet" - summary_help: "Son kullanıcılara gösterilecek araçların amacının özeti" - script: "Komut Dosyası" - parameters: "Parametreler" - save: "Kaydet" - remove_parameter: "Kaldır" - parameter_required: "Gerekli" - parameter_enum: "Numaralandırma" - parameter_name: "Parametre adı" - parameter_description: "Parametre açıklaması" - enum_value: "Numaralandırma değeri" - add_enum_value: "Numaralandırma değeri ekle" - edit: "Düzenle" - test: "Testi çalıştır" - delete: "Sil" - saved: "Araç kaydedildi" - confirm_delete: "Bu aracı silmek istediğinizden emin misiniz?" - test_modal: - title: "YZ aracını test edin" - run: "Testi çalıştır" - result: "Test sonucu" - llms: - short_title: "LLM'ler" - no_llms: "Henüz LLM yok" - new: "Yeni model" - display_name: "Ad" - name: "Model kimliği" - provider: "Sağlayıcı" - tokenizer: "Token'laştırıcı" - url: "Modeli barındıran hizmetin URL'si" - api_key: "Modeli barındıran hizmetin API Anahtarı" - enabled_chat_bot: "YZ bot seçicisine izin verin" - vision_enabled: "Vizyon etkin" - ai_bot_user: "YZ botu Kullanıcısı" - save: "Kaydet" - edit: "Düzenle" - saved: "LLM modeli kaydedildi" - back: "Geri" - confirm_delete: Bu modeli silmek istediğinizden emin misiniz? - delete: Sil - seeded_warning: "Bu model sitenizde önceden yapılandırılmış ve düzenlenemez." - quotas: - title: "Kullanım kotaları" - add_title: "Yeni kota oluşturun" - group: "Grup" - max_tokens: "Maks. token sayısı" - max_usages: "Maksimum kullanım" - duration: "Süre" - confirm_delete: "Bu kotayı silmek istediğinizden emin misiniz?" - add: "Kota ekle" - durations: - hour: "1 saat" - six_hours: "6 saat" - day: "24 saat" - week: "7 gün" - custom: "Özel..." - hours: "saat" - max_tokens_help: "Bu gruptaki her kullanıcının belirtilen süre içinde kullanabileceği maksimum token (kelime ve karakter) sayısı. Token'lar, YZ modelleri tarafından metni işlemek için kullanılan birimlerdir; kabaca 1 token = 4 karakter veya bir kelimenin 3/4'ü." - max_usages_help: "Bu gruptaki her kullanıcının belirtilen süre içinde yapay zekâ modelini kullanabileceği maksimum sayı. Bu kota, grup genelinde paylaşılmaz, bireysel kullanıcı başına izlenir." - usage: - ai_bot: "YZ botu" - ai_helper: "Yardımcı" - ai_persona: "Kişilik (%{persona})" - ai_summarization: "Özetle" - ai_embeddings_semantic_search: "YZ araması" - ai_spam: "İstenmeyen içerik" - in_use_warning: - one: "Bu model şu anda %{settings} tarafından kullanılıyor. Yanlış yapılandırılırsa özellik beklendiği gibi çalışmaz." - other: "Bu model şu anda şunlar tarafından kullanılıyor: %{settings}. Yanlış yapılandırılırsa özellikler beklendiği gibi çalışmaz. " - model_description: - none: "Çoğu dil modeli için çalışan genel ayarlar" - anthropic-claude-opus-4-0: "Antropic'in en akıllı modeli" - anthropic-claude-3-5-haiku-latest: "Hızlı ve uygun maliyetli" - google-gemini-2-5-flash: "Çok modlu muhakeme ile hafif, hızlı ve uygun maliyetli" - samba_nova-Meta-Llama-3-1-8B-Instruct: "Verimli hafif çok dilli model" - samba_nova-Meta-Llama-3-3-70B-Instruct": "Güçlü çok amaçlı model" - mistral-mistral-large-latest: "Mistral'in en güçlü modeli" - mistral-pixtral-large-latest: "Mistral'in en güçlü görüş yeteneğine sahip modeli" - preseeded_model_description: "%{model} kullanan önceden yapılandırılmış açık kaynaklı model" - configured: - title: "Yapılandırılmış LLM'ler" - preconfigured_llms: "LLM'nizi seçin" - preconfigured: - title_no_llms: "Başlamak için bir şablon seçin" - title: "Yapılandırılmamış LLM şablonları" - description: "LLM'ler (Büyük Dil Modelleri) içeriği özetlemek, raporlar oluşturmak, müşteri etkileşimlerini otomatikleştirmek ve forum moderasyonunu ve içgörülerini kolaylaştırmak gibi görevler için optimize edilmiş YZ araçlarıdır" - fake: "Manuel yapılandırma" - button: "Kur" - next: - title: "İleri" - tests: - title: "Testi çalıştır" - running: "Test çalıştırılıyor..." - success: "Başarılı!" - failure: "Modelle iletişim kurmaya çalışmak şu hatayı döndürdü: %{error}" - hints: - name: "Hangi modeli kullanacağımızı belirtmek için bunu API çağrısına dâhil ediyoruz" - vision_enabled: "Etkinleştirilirse YZ görüntüleri anlamaya çalışır. Görmeyi destekleyen kullanılan modele bağlıdır. Anthropic, Google ve OpenAI'ın en son modelleri tarafından desteklenir." - enabled_chat_bot: "Etkinleştirilirse kullanıcılar YZ botu ile kişisel mesajlar oluştururken bu modeli seçebilir" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "Özel" - provider_fields: - access_key_id: "AWS Bedrock erişim anahtarı kimliği" - region: "AWS Bedrock bölgesi" - organization: "İsteğe bağlı OpenAI kuruluş kimliği" - disable_system_prompt: "İstemlerde sistem mesajını devre dışı bırakın" - enable_native_tool: "Yerel araç desteğini etkinleştirin" - disable_native_tools: "Yerel araç desteğini devre dışı bırakın (XML tabanlı araçlar kullanın)" - provider_order: "Sağlayıcı sırası (virgülle ayrılmış liste)" - provider_quantizations: "Sağlayıcı nicelleştirmelerinin sırası (virgülle ayrılmış liste, ör.: fp16,fp8)" - disable_streaming: "Akış tamamlamalarını devre dışı bırak (akışı akış dışı taleplere dönüştür)" - reasoning_effort: "Muhakeme eforu (yalnızca muhakeme modelleri için)" - related_topics: - title: "İlgili konular" - pill: "İlgili" - ai_helper: - title: "YZ kullanarak değişiklik önerin" - description: "Aşağıdaki seçeneklerden birini belirleyin ve YZ size metnin yeni bir versiyonunu önersin." - selection_hint: "İpucu: Ayrıca, yalnızca onu yeniden yazmak için yardımcıyı açmadan önce metnin bir bölümünü de seçebilirsiniz." - suggest: "YZ ile öner" - suggest_errors: - too_many_tags: - one: "En fazla sadece %{count} etiketine sahip olabilirsiniz" - other: "En fazla sadece %{count} etiketine sahip olabilirsiniz" - no_suggestions: "Mevcut öneri yok" - missing_content: "Lütfen öneri oluşturmak için biraz içerik girin." - context_menu: - trigger: "YZ'ye sor" - loading: "YZ oluşturuyor" - cancel: "İptal et" - confirm: "Onayla" - discard: "Kapat" - changes: "Önerilen düzenlemeler" - custom_prompt: - title: "Özel istem" - placeholder: "Özel bir istem girin..." - submit: "İstem gönder" - translate_prompt: "%{language} diline çevir" - post_options_menu: - trigger: "YZ'ye sor" - title: "YZ'ye sor" - loading: "YZ oluşturuyor" - close: "Bitir" - copy: "Kopyala" - copied: "Kopyalandı!" - cancel: "İptal et" - insert_footnote: "Dipnot ekle" - footnote_disabled: "Otomatik ekleme devre dışı bırakıldı, kopyala düğmesine tıklayın ve manuel olarak düzenleyin" - footnote_credits: "YZ ile açıklama" - fast_edit: - suggest_button: "Düzenleme öner" - thumbnail_suggestions: - title: "Önerilen küçük resimler" - select: "Seç" - selected: "Seçili" - image_caption: - button_label: "YZ ile alt yazı" - generating: "Alt yazı oluşturuluyor..." - credits: "Alt yazısı YZ tarafından hazırlandı" - save_caption: "Kaydet" - automatic_caption_setting: "Otomatik alt yazıyı etkinleştirin" - automatic_caption_loading: "Görüntülere alt yazı ekleniyor..." - automatic_caption_dialog: - prompt: "Bu gönderide alt yazısız görüntüler bulunuyor. Görüntü yüklemelerinde otomatik alt yazıları etkinleştirmek ister misiniz? (Bu daha sonra tercihlerinizden değiştirilebilir)" - confirm: "Etkinleştir" - cancel: "Bir daha sorma" - no_content_error: "Üzerinde YZ eylemleri gerçekleştirmek için önce içerik ekleyin" - reviewables: - model_used: "Kullanılan model:" - accuracy: "Doğruluk:" - embeddings: - short_title: "Gömmeler" - new: "Yeni gömme" - back: "Geri" - save: "Kaydet" - saved: "Gömme yapılandırması kaydedildi" - delete: "Sil" - confirm_delete: Bu gömme yapılandırmasını kaldırmak istediğinizden emin misiniz? - empty: "Henüz yerleştirmeleri ayarlamadınız" - presets: "Ön ayar seçin..." - configure_manually: "Manuel olarak yapılandırın" - edit: "Düzenle" - seeded_warning: "Bu, sitenizde önceden yapılandırılmış ve düzenlenemez." - tests: - title: "Testi çalıştır" - running: "Test çalıştırılıyor..." - success: "Başarılı!" - failure: "Bir gömme oluşturmaya çalışmak şu sonucu verdi: %{error}" - hints: - dimensions_warning: "Kaydedildikten sonra bu değer değiştirilemez." - matryoshka_dimensions: "Verilerin hiyerarşik veya çok katmanlı temsili için kullanılan iç içe yerleştirmelerin boyutunu tanımlar, iç içe geçmiş matruşka bebeklerinin birbirinin içine sığmasına benzer." - sequence_length: "Gömmeler oluştururken veya bir sorguyu işlerken aynı anda işlenebilecek maksimum belirteç sayısı." - distance_function: "Gömmeler arasındaki benzerliğin, kosinüs mesafesi (vektörler arasındaki açıyı ölçer) veya negatif iç çarpım (vektör değerlerinin örtüşmesini ölçer) kullanılarak nasıl hesaplanacağını belirler." - display_name: "Ad" - provider: "Sağlayıcı" - url: "Gömme hizmeti URL'si" - api_key: "Gömme hizmeti API Anahtarı" - tokenizer: "Token'laştırıcı" - dimensions: "Gömme boyutları" - max_sequence_length: "Dizi uzunluğu" - embed_prompt: "Gömme istemi" - search_prompt: "Arama istemi" - matryoshka_dimensions: "Matruşka boyutları" - distance_function: "Mesafe işlevi" - distance_functions: - "<#>": "Negatif iç çarpım" - <=>: "Kosinüs mesafesi" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "Özel" - provider_fields: - model_name: "Model adı" - semantic_search: "Konular (Semantik)" - semantic_search_loading: "YZ kullanarak daha fazla sonuç arama" - semantic_search_results: - toggle: "YZ kullanılarak bulunan %{count} sonuçları gösteriliyor" - toggle_hidden: "YZ kullanılarak bulunan %{count} sonuç gizleniyor" - none: "Üzgünüz, YZ aramamız eşleşen konu bulamadı" - new: "YZ ile yeni sonuçlar aramaya başlamak için \"ara\" seçeneğine basın" - unavailable: "YZ sonuçları kullanılamıyor" - semantic_search_tooltips: - results_explanation: "Etkinleştirildiğinde, ek YZ arama sonuçları aşağıya eklenir." - invalid_sort: "YZ sonuçlarını göstermek için arama sonuçları Alaka Düzeyine göre sıralanmalıdır" - semantic_search_unavailable_tooltip: "YZ sonuçlarını göstermek için arama sonuçları Alaka Düzeyine göre sıralanmalıdır" - ai_generated_result: "YZ kullanılarak bulunan arama sonucu" - quick_search: - suffix: "YZ ile tüm konu ve gönderilerde" - ai_artifact: - expand_view_label: "Görünümü genişlet" - collapse_view_label: "Tam Ekrandan Çık (ESC veya Geri tuşu)" - click_to_run_label: "Artifact'i çalıştır" - ai_bot: - llm: "Model" - pm_warning: "YZ sohbet robotu mesajları moderatörler tarafından düzenli olarak izlenir." - cancel_streaming: "Yanıtı durdur" - default_pm_prefix: "[Adsız YZ botu kişisel mesajı]" - shortcut_title: "YZ botuyla kişisel mesaj başlatın" - share: "YZ konuşmasını kopyala" - conversation_shared: "Konuşma kopyalandı" - debug_ai: "Ham YZ isteğini ve yanıtını görüntüleyin" - debug_ai_modal: - title: "YZ etkileşimini görüntüleyin" - copy_request: "İsteği kopyala" - copy_response: "Yanıtı kopyala" - request_tokens: "İstek token'ları:" - response_tokens: "Yanıt token'ları:" - request: "İstek" - response: "Yanıt" - next_log: "Sonraki" - previous_log: "Önceki" - share_full_topic_modal: - title: "Konuşmayı herkese açık olarak paylaşın" - share: "Bağlantıyı paylaşın ve kopyalayın" - update: "Bağlantıyı güncelleyin ve kopyalayın" - delete: "Paylaşımı sil" - share_ai_conversation: - name: "YZ konuşmasını paylaş" - title: "Bu YZ konuşmasını herkese açık olarak paylaşın" - invite_ai_conversation: - button: "Davet et" - ai_label: "YZ" - ai_title: "YZ ile konuşma" - share_modal: - title: "YZ konuşmasını kopyala" - copy: "Kopyala" - context: "Paylaşılacak etkileşimler:" - share_tip: "Alternatif olarak, konuşmanın tamamını paylaşabilirsiniz" - bot_names: - fake: "Sahte Test Botu" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Bugün" - last_7_days: "Son 7 gün" - last_30_days: "Son 30 gün" - sentiments: - dashboard: - title: "Duyarlılık" - sentiment_analysis: - filter_types: - all: "Hepsi" - positive: "Pozitif" - neutral: "Nötr" - negative: "Negatif" - group_types: - category: "Kategori" - tag: "Etiket" - table: - sentiment: "Duyarlılık" - total_count: "Toplam" - summarization: - chat: - title: "Mesajları özetle" - description: "İstediğiniz zaman diliminde gönderilen konuşmayı özetlemek için aşağıdaki seçeneklerden birini belirleyin." - summarize: "Özetle" - since: - one: "Son bir saat" - other: "Son %{count} saat" - topic: - title: "Konu özeti" - close: "Özet panelini kapatın" - topic_list_layout: - button: - compact: "Kompakt" - expanded: "Genişletilmiş" - expanded_description: "YZ özetleriyle" - discobot_discoveries: - regular_results: "Konular" - collapse: "Daralt" - tooltip: - actions: - disable: "Devredışı" - review: - types: - reviewable_ai_post: - title: "YZ Bayraklı gönderi" - reviewable_ai_chat_message: - title: "YZ Bayraklı sohbet mesajı" diff --git a/config/locales/client.ug.yml b/config/locales/client.ug.yml deleted file mode 100644 index f7f2d3f6..00000000 --- a/config/locales/client.ug.yml +++ /dev/null @@ -1,196 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ug: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "تەرتىپى" - tag: - label: "بەلگە" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "يوللىغۇچى" - topic_id: - label: "تېما كىملىكى" - title: - label: "ئاتاق" - categories: - label: "سەھىپە" - tags: - label: "بەلگە" - llm_triage: - fields: - category: - label: "سەھىپە" - tags: - label: "بەلگە" - canned_reply: - label: "جاۋاب" - discourse_ai: - features: - back: "كەينى" - disabled: "(چەكلەندى)" - groups: "گۇرۇپپا:" - no_persona: "تەڭشەلمىدى" - no_groups: "يوق" - edit: "تەھرىر" - expand_list: - one: "(%{count} تېخىمۇ كۆپ)" - other: "(%{count} تېخىمۇ كۆپ)" - collapse_list: "(ئازراق كۆرسەت)" - filters: - all: "ھەممىسى" - reset: "ئەسلىگە قايتۇر" - search: - name: "ئىزدە" - spam: - name: "ئەخلەت خەت" - modals: - select_option: "تاللانما تاللىنىدۇ..." - spam: - short_title: "ئەخلەت خەت" - enable: "قوزغات" - test_modal: - result: "نەتىجە" - spam: "ئەخلەت خەت" - usage: - summary: "خۇلاسە" - username: "ئىشلەتكۈچى ئاتى" - total_requests: "جەمئىي ئىلتىماس" - periods: - custom: "ئىختىيارى..." - ai_persona: - back: "كەينى" - name: "ئىسمى" - edit: "تەھرىر" - export: "چىقار" - description: "چۈشەندۈرۈش" - user: ئىشلەتكۈچى - save: "ساقلا" - enabled: "قوزغىتىلدى؟" - delete: "ئۆچۈر" - response_format: - open_modal: "تەھرىر" - modal: - key_title: "ئاچقۇچ" - filters: - reset: "ئەسلىگە قايتۇر" - rag: - uploads: - title: "يۈكلەنمە" - uploading: "يۈكلەۋاتىدۇ..." - tools: - back: "كەينى" - export: "چىقار" - name: "ئىسمى" - description: "چۈشەندۈرۈش" - summary: "خۇلاسە" - script: "قوليازما" - save: "ساقلا" - remove_parameter: "چىقىرىۋەت" - parameter_required: "زۆرۈر" - edit: "تەھرىر" - delete: "ئۆچۈر" - llms: - display_name: "ئىسمى" - save: "ساقلا" - edit: "تەھرىر" - back: "كەينى" - delete: ئۆچۈر - quotas: - group: "گۇرۇپپا" - max_usages: "ئەڭ كۆپ ئىشلىتىلىشى" - duration: "داۋاملىشىش ۋاقتى" - durations: - hour: "1 سائەت" - six_hours: "6 سائەت" - day: "24 سائەت" - week: "7 كۈن" - custom: "ئىختىيارى..." - hours: "سائەت" - usage: - ai_summarization: "خۇلاسە" - ai_spam: "ئەخلەت خەت" - next: - title: "كېيىنكى" - tests: - success: "مۇۋەپپەقىيەتلىك!" - providers: - google: "Google" - fake: "ئىختىيارى" - ai_helper: - context_menu: - cancel: "ۋاز كەچ" - confirm: "جەزملە" - discard: "تاشلىۋەت" - post_options_menu: - close: "تاقا" - copy: "كۆچۈر" - copied: "كۆچۈرۈلدى!" - cancel: "ۋاز كەچ" - thumbnail_suggestions: - select: "تاللا" - selected: "تاللاندى" - image_caption: - save_caption: "ساقلا" - automatic_caption_dialog: - confirm: "قوزغات" - embeddings: - back: "كەينى" - save: "ساقلا" - delete: "ئۆچۈر" - edit: "تەھرىر" - tests: - title: "سىناقنى ئىجرا قىل" - success: "مۇۋەپپەقىيەتلىك!" - display_name: "ئىسمى" - providers: - google: "Google" - fake: "ئىختىيارى" - ai_bot: - debug_ai_modal: - request: "ئىلتىماس" - response: "ئىنكاس" - next_log: "كېيىنكى" - previous_log: "ئالدىنقى" - invite_ai_conversation: - button: "تەكلىپ" - share_modal: - copy: "كۆچۈر" - conversations: - today: "بۈگۈن" - sentiments: - sentiment_analysis: - filter_types: - all: "ھەممىسى" - neutral: "بىتەرەپ" - group_types: - category: "سەھىپە" - tag: "بەلگە" - table: - total_count: "جەمئىي" - summarization: - chat: - title: "ئۇچۇرنى خۇلاسىلە" - description: "تۆۋەندىكى تاللانما تاللانسا لازىملىق ۋاقىت دائىرىسىدە يوللانغان سۆھبەتنى خۇلاسىلەيدۇ." - summarize: "خۇلاسە" - since: - one: "ئاخىرقى %{count} سائەت" - other: "ئاخىرقى %{count} سائەت" - discobot_discoveries: - regular_results: "تېما" - collapse: "يىغ" - tooltip: - actions: - disable: "چەكلە" diff --git a/config/locales/client.uk.yml b/config/locales/client.uk.yml deleted file mode 100644 index a500b7c8..00000000 --- a/config/locales/client.uk.yml +++ /dev/null @@ -1,482 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -uk: - admin_js: - admin: - site_settings: - categories: - discourse_ai: "Дискурс ШІ" - dashboard: - reports: - filters: - sort_by: - label: "Сортувати за" - tag: - label: "Теґ" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "Відправник" - description: "Користувач, який надсилатиме звіт" - receivers: - label: "Отримувачі" - description: "Користувачі, які отримають звіт (електронні листи будуть надіслані безпосередньо на електронну пошту, імена користувачів будуть надіслані в приватному повідомленні)" - topic_id: - label: "ID теми" - title: - label: "Назва" - description: "Назва звіту" - days: - label: "Дні" - description: "Часові рамки звіту" - offset: - label: "Зсув" - description: "Під час тестування ви можете запустити звіт за минулі періоди, використовуйте зсув, щоб почати звіт із більш ранньої дати" - instructions: - label: "Інструкції" - description: "Інструкції, надані до великої мовної моделі" - sample_size: - label: "Розмір вибірки" - description: "Кількість дописів до вибірки для звіту" - tokens_per_post: - label: "Токени на пост" - model: - label: "Модель" - description: "LLM для створення звітів" - categories: - label: "Розділи" - description: "Фільтрувати теми лише за цими категоріями" - tags: - label: "Теґи" - description: "Фільтрувати теми лише за цими тегами" - exclude_tags: - label: "Виключити теги" - description: "Виключити теми з цими тегами" - exclude_categories: - label: "Виключити категорії" - description: "Виключити теми з цими категоріями" - allow_secure_categories: - label: "Дозволити захищені категорії" - description: "Дозволити створення звіту для тем у захищених категоріях" - suppress_notifications: - label: "Вимкнути сповіщення" - description: "Приховати сповіщення, які може надсилати звіт шляхом перетворення їх у контент. Це змінить згадки та внутрішні посилання." - debug_mode: - label: "Режим налагодження" - description: "Увімкнути режим налагодження, щоб побачити необроблені вхідні та вихідні дані LLM" - priority_group: - label: "Пріоритетна група" - description: "Визначте пріоритетність контенту з цієї групи у звіті" - temperature: - label: "Температура" - top_p: - label: "Top P" - llm_tool_triage: - fields: - model: - label: "Модель" - llm_triage: - fields: - system_prompt: - label: "Системний запит" - description: "Підказка, яка буде використана для сортування. Для відповіді використовуйте одне слово, яке ви зможете використовувати для запуску дії" - max_post_tokens: - label: "Максимальна кількість токенів публікації" - description: "Максимальна кількість токенів для сканування за допомогою LLM" - search_for_text: - label: "Пошук тексту" - category: - label: "Категорія" - description: "Категорія, яку слід застосувати до теми" - tags: - label: "Теґи" - description: "Теги, які слід застосувати до теми" - canned_reply: - label: "Відповідь" - canned_reply_user: - label: "Відповідь користувача" - description: "Ім’я користувача для публікації стандартної відповіді" - hide_topic: - label: "Приховати тему" - flag_type: - label: "Тип прапора" - description: "Тип прапорця, який буде застосовано до повідомлення (спам або просто підняти на розгляд)" - flag_post: - label: "Поскаржитися" - description: "Позначає повідомлення (як спам, або для перегляду)" - model: - label: "Модель" - description: "Мовна модель, що використовується для тренування" - temperature: - label: "Температура" - discourse_ai: - title: "ШІ" - features: - back: "Назад" - disabled: "(вимкнено)" - groups: "Групи:" - no_persona: "Не встановлено" - no_groups: "Немає" - edit: "Редагувати" - expand_list: - one: "(ще %{count})" - few: "(ще %{count})" - many: "(ще %{count})" - other: "(ще %{count})" - collapse_list: "(показати менше)" - filters: - all: "Все" - reset: "Скинути" - search: - name: "Пошук" - ai_helper: - proofread: Перевірити текст - explain: "Пояснити" - markdown_tables: "Створити таблицю у форматі Markdown" - spam: - name: "Спам" - modals: - select_option: "Вибрати опцію..." - spam: - short_title: "Спам" - last_seven_days: "Останні 7 днів" - enable: "Увімкнути" - test_modal: - result: "Результат" - spam: "Спам" - usage: - summary: "Підсумок" - model: "Модель" - username: "Імʼя користувача" - total_requests: "Всього" - request_tokens: "Токени запиту" - response_tokens: "Токени відповіді" - periods: - last_day: "Останні 24 години" - custom: "Власний…" - ai_persona: - ai_tools: "Інструменти" - tool_strategies: - all: "Застосувати до всіх відповідей" - replies: - one: "Застосувати тільки до першої відповіді" - few: "Застосувати тільки до перших %{count} відповідей" - many: "Застосувати тільки до перших %{count} відповідей" - other: "Застосувати тільки до перших %{count} відповідей" - back: "Назад" - name: "Імʼя" - edit: "Редагувати" - export: "Експорт" - description: "Опис" - no_llm_selected: "Мовну модель не вибрано" - max_context_posts_help: "Максимальна кількість дописів, які ШІ буде використовувати як контекст для відповіді користувачеві. (за замовчуванням порожньо)" - vision_enabled: Візуальне розпізнавання увімкнено - vision_enabled_help: Якщо ввімкнено, ШІ намагатиметься зрозуміти зображення, які користувачі публікують у темі, залежно від моделі, яка використовується для підтримки зору. Підтримується останніми моделями від Anthropic, Google і OpenAI. - vision_max_pixels: Підтримуваний розмір зображення - tool_details_help: Показуватиме кінцевим користувачам докладні відомості про те, які інструменти запустила мовна модель. - mentionable_help: Якщо увімкнено, користувачі в дозволених групах можуть згадувати цього користувача в повідомленнях, ШІ відповідатиме від імені цієї персони. - user: Користувач - create_user_help: За бажанням ви можете приєднати користувача до цієї персони. Якщо ви це зробите, ШІ використовуватиме цього користувача, щоб відповідати на запити. - default_llm_help: Мовна модель за замовчуванням для цієї персони. Обов'язково, якщо ви хочете згадувати персону в публічних публікаціях. - question_consolidator_llm: Мовна модель для Question Consolidator - question_consolidator_llm_help: Мовна модель для об’єднання запитань, ви можете вибрати менш потужну модель, щоб заощадити кошти. - allow_chat_direct_messages_help: "Якщо увімкнено, користувачі з дозволених груп можуть надсилати прямі повідомлення цій персоні." - allow_chat_channel_mentions_help: "Якщо увімкнено, користувачі в дозволених групах можуть згадувати цю персону в чаті." - allow_personal_messages_help: "Якщо увімкнено, користувачі з дозволених груп можуть надсилати особисті повідомлення цій персоні." - allow_topic_mentions_help: "Якщо увімкнено, користувачі в дозволених групах можуть згадувати цю персону в темах." - save: "Зберегти" - enabled: "Включено?" - allowed_groups: "Дозволені групи" - confirm_delete: "Ви впевнені, що хочете видалити цю персону?" - title: "Персони" - short_title: "Персони" - delete: "Видалити" - temperature: "Температура" - top_p: "Top P" - top_p_help: "Top P параметр для LLM, збільште, щоб збільшити випадковість (залиште порожнім, щоб використовувати модель за замовчуванням, як правило, значення від 0,0 до 1,0)" - priority: "Пріоритет" - priority_help: "Пріоритетні персони відображаються користувачам у верхній частині списку персон. Якщо кілька персон мають пріоритет, вони будуть сортовані за алфавітом." - rag_conversation_chunks_help: "Кількість фрагментів для пошуку за моделлю RAG. Збільшуйте, щоб збільшити кількість контексту, який може використовувати ШІ." - response_format: - open_modal: "Редагувати" - modal: - key_title: "Ключ" - list: - enabled: "AI Bot?" - filters: - reset: "Скинути" - rag: - options: - rag_chunk_tokens_help: "Кількість токенів, які будуть використані для кожного фрагмента в моделі RAG. Збільште, щоб збільшити кількість контексту, який може використовувати ШІ. (Зміна призведе до повторної переіндексації всіх завантажень)" - rag_chunk_overlap_tokens_help: "Кількість токенів для перекриття між фрагментами в моделі RAG. (Зміна буде переіндексовувати всі завантаження)" - uploads: - title: "Завантаження" - filter: "Фільтр завантажень" - indexed: "Індексовано" - indexing: "Індексація" - uploaded: "Готовий до індексації" - uploading: "Надсилання…" - remove: "Видалити завантаження" - tools: - back: "Назад" - short_title: "Інструменти" - export: "Експорт" - name: "Імʼя" - description: "Опис" - description_help: "Чіткий опис призначення інструменту для мовної моделі" - summary: "Підсумок" - summary_help: "Короткий опис призначення інструментів для відображення кінцевим користувачам" - script: "Скрипт" - parameters: "Параметри" - save: "Зберегти" - remove_parameter: "Вилучити" - parameter_required: "Обов'язкові" - parameter_enum: "Перерахунок" - enum_value: "Значення переліку" - add_enum_value: "Додати значення переліку" - edit: "Редагувати" - delete: "Видалити" - saved: "Інструмент збережено" - confirm_delete: "Ви впевнені, що хочете видалити цей інструмент?" - llms: - short_title: "LLMs" - no_llms: "Ще немає LLM" - display_name: "Імʼя" - name: "ID моделі" - provider: "Провайдер" - tokenizer: "Токенізатор" - url: "URL-адреса сервісу, на якому розміщена модель" - api_key: "Ключ API сервісу, на якому розміщена модель" - vision_enabled: "Візуальне розпізнавання увімкнено" - save: "Зберегти" - edit: "Редагувати" - back: "Назад" - confirm_delete: Ви впевнені, що хочете видалити цей тип? - delete: Видалити - seeded_warning: "Ця модель попередньо налаштована на вашому сайті і її неможливо редагувати." - quotas: - group: "Група" - max_usages: "Максимальна кількість використань" - duration: "Тривалість" - durations: - hour: "1 година" - six_hours: "6 годин" - day: "24 години" - week: "7 днів" - custom: "Власний…" - hours: "годин" - usage: - ai_persona: "Персона (%{persona})" - ai_summarization: "Підсумок" - ai_spam: "Спам" - in_use_warning: - one: "Ця модель зараз використовується %{settings}. У разі неправильного налаштування функція не працюватиме належним чином." - few: "Ця модель зараз використовується: %{settings}. У разі неправильного налаштування функція не працюватиме належним чином. " - many: "Ця модель зараз використовується: %{settings}. У разі неправильного налаштування функція не працюватиме належним чином. " - other: "Ця модель зараз використовується: %{settings}. У разі неправильного налаштування функція не працюватиме належним чином. " - model_description: - none: "Загальні налаштування, що працюють для більшості мовних моделей" - anthropic-claude-opus-4-0: "Найрозумніша модель Anthropic" - anthropic-claude-3-5-haiku-latest: "Швидко та економічно ефективно" - google-gemini-2-5-flash: "Легкий, швидкий та економічний з мультимодальними міркуваннями" - configured: - title: "Налаштовані LLM" - preconfigured_llms: "Виберіть вашу LLM" - preconfigured: - title_no_llms: "Виберіть шаблон для початку" - title: "Неналаштовані шаблони LLM" - fake: "Ручне налаштування" - button: "Налаштувати" - next: - title: "Далі" - tests: - running: "Виконується тест..." - success: "Успіх!" - failure: "Спроба зв’язатися з моделлю повернула таку помилку: %{error}" - hints: - vision_enabled: "Якщо увімкнено, ШІ намагатиметься зрозуміти зображення. Це залежить від використовуваної моделі, що підтримує зір. Підтримується останніми моделями від Anthropic, Google та OpenAI." - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - fake: "Користувацька" - provider_fields: - disable_system_prompt: "Вимкнути системні повідомлення у підказках" - enable_native_tool: "Увімкнути підтримку рідних інструментів" - disable_native_tools: "Вимкнути підтримку рідних інструментів (використовувати інструменти на основі XML)" - related_topics: - pill: "Пов'язані" - ai_helper: - title: "Пропонувати зміни за допомогою ШІ" - description: "Виберіть один із варіантів нижче, і ШІ запропонує вам нову версію тексту." - selection_hint: "Підказка: Ви також можете вибрати частину тексту перед відкриттям помічника, щоб переписати лише її." - suggest: "Підказки від ШІ" - missing_content: "Будь ласка, введіть деякий контент для створення пропозицій." - context_menu: - trigger: "Запитайте ШІ" - loading: "ШІ генерує" - cancel: "Скасувати" - confirm: "Підтвердити" - discard: "Відкинути" - custom_prompt: - placeholder: "Введіть власний запит..." - translate_prompt: "Перекласти на %{language}" - post_options_menu: - trigger: "Запитайте ШІ" - title: "Запитайте ШІ" - loading: "ШІ генерує" - close: "Закрити" - copy: "Копіювати" - copied: "Скопійовано!" - cancel: "Скасувати" - insert_footnote: "Додати виноску" - footnote_credits: "Пояснення ШІ" - thumbnail_suggestions: - select: "Вибрати" - selected: "Вибрано" - image_caption: - button_label: "Підпис зі ШІ" - generating: "Створення підпису..." - credits: "Підпис від ШІ" - save_caption: "Зберегти" - automatic_caption_loading: "Підписи до зображень..." - automatic_caption_dialog: - confirm: "Увімкнути" - cancel: "Не запитувати знову" - no_content_error: "Спочатку додайте вміст, щоб застосувати до нього дії ШІ" - reviewables: - model_used: "Використана модель:" - accuracy: "Точність:" - embeddings: - back: "Назад" - save: "Зберегти" - delete: "Видалити" - presets: "Виберіть попереднє налаштування..." - edit: "Редагувати" - tests: - title: "Запустити тест" - running: "Виконується тест..." - success: "Успіх!" - display_name: "Імʼя" - provider: "Провайдер" - tokenizer: "Токенізатор" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - CDCK: "CDCK" - fake: "Користувацька" - semantic_search: "Теми (семантикв)" - semantic_search_loading: "Пошук більшої кількості результатів за допомогою ШІ" - semantic_search_results: - toggle: "Показати результати %{count} , знайдені за допомогою ШІ" - toggle_hidden: "Приховати %{count} результатів, знайдених за допомогою ШІ" - none: "Вибачте, наш пошук ШІ не знайшов відповідних тем" - ai_generated_result: "Результат пошуку знайдено за допомогою ШІ" - quick_search: - suffix: "у всіх темах і повідомленнях з ШІ" - ai_bot: - llm: "Модель" - pm_warning: "Повідомлення чат-ботів ШІ регулярно контролюються модераторами." - cancel_streaming: "Зупинити відповідь" - default_pm_prefix: "[ПП від ШІ-бота без назви]" - shortcut_title: "Почніть листування з AI-ботом" - share: "Копіювати розмову ШІ" - conversation_shared: "Бесіду скопійовано" - debug_ai: "Перегляньте необроблений запит і відповідь ШІ" - debug_ai_modal: - title: "Переглянути взаємодію зі ШІ" - copy_request: "Скопіювати запит" - copy_response: "Копіювати відповідь" - request_tokens: "Токени запиту:" - response_tokens: "Токени відповіді:" - request: "Запит" - response: "відповідь" - next_log: "Далі" - previous_log: "Попередні" - share_ai_conversation: - title: "Поділіться цією бесідою зі ШІ публічно" - invite_ai_conversation: - button: "Запрошення" - ai_label: "ШІ" - ai_title: "Розмова з ШІ" - share_modal: - title: "Копіювати розмову ШІ" - copy: "Копіювати" - context: "Взаємодія для спільного використання:" - share_tip: "Крім того, ви можете поділитися усією розмовою" - bot_names: - fake: "Фальшивий тестовий бот" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "Сьогодні" - last_7_days: "Останні 7 днів" - last_30_days: "Останні 30 днів" - sentiments: - dashboard: - title: "Почуття" - sentiment_analysis: - filter_types: - all: "Все" - positive: "Позитивний" - neutral: "Нейтральна" - negative: "Негативний" - group_types: - category: "Розділ" - tag: "Теґ" - table: - sentiment: "Почуття" - total_count: "Всього" - summarization: - chat: - title: "Підсумкове повідомлення" - description: "Виберіть опцію нижче, щоб підсумувати розмову, надіслану протягом визначеного періоду." - summarize: "Підсумок" - since: - one: "Остання година" - few: "Останні %{count} години" - many: "Останні %{count} годин" - other: "Останні %{count} годин" - topic: - title: "Підсумок теми" - close: "Закрити панель підсумків" - discobot_discoveries: - regular_results: "Теми" - collapse: "Згорнути" - tooltip: - actions: - disable: "Вимкнути" - review: - types: - reviewable_ai_post: - title: "Повідомлення з позначкою ШІ" - reviewable_ai_chat_message: - title: "Повідомлення чату з прапорцем ШІ" diff --git a/config/locales/client.ur.yml b/config/locales/client.ur.yml deleted file mode 100644 index 7bc4358b..00000000 --- a/config/locales/client.ur.yml +++ /dev/null @@ -1,184 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ur: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "ترتیب بہ" - tag: - label: "ٹیگ" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ٹاپک ID" - title: - label: "عنوان" - categories: - label: "اقسام" - tags: - label: "ٹیگز" - llm_triage: - fields: - category: - label: "زمرہ" - tags: - label: "ٹیگز" - canned_reply: - label: "جواب" - discourse_ai: - features: - back: "واپس" - disabled: "(غیر فعال)" - groups: "گروپس:" - no_groups: "کوئی نہیں" - edit: "ترمیم کریں" - expand_list: - one: "(%{count} مزید)" - other: "(%{count} مزید)" - filters: - all: "تمام" - reset: "رِی سَیٹ" - search: - name: "تلاش کریں" - spam: - name: "سپَیم" - modals: - select_option: "ایک آپشن منتخب کریں..." - spam: - short_title: "سپَیم" - last_seven_days: "پچھلے 7 دن" - enable: "فعال کریں" - test_modal: - spam: "سپَیم" - usage: - summary: "Summary" - username: "صارف نام" - total_requests: "کُل درخواستیں" - periods: - last_day: "پچھلے 24 گھنٹے" - custom: "ترمیم..." - ai_persona: - back: "واپس" - name: "نام" - edit: "ترمیم کریں" - export: "برآمد" - description: "تفصیل" - user: صارف - save: "محفوظ کریں" - enabled: "فعال؟" - delete: "مٹائیں" - response_format: - open_modal: "ترمیم کریں" - modal: - key_title: "کی" - filters: - reset: "رِی سَیٹ" - rag: - uploads: - title: "اَپ لوڈز" - uploading: "اَپ لوڈ کیا جا رہا ہے..." - tools: - back: "واپس" - export: "برآمد" - name: "نام" - description: "تفصیل" - summary: "Summary" - save: "محفوظ کریں" - remove_parameter: "خارج کریں" - parameter_required: "درکار" - edit: "ترمیم کریں" - delete: "مٹائیں" - llms: - display_name: "نام" - save: "محفوظ کریں" - edit: "ترمیم کریں" - back: "واپس" - delete: مٹائیں - quotas: - group: "گروپ" - max_usages: "زیادہ سے زیادہ استعمال" - duration: "دورانیہ" - durations: - hour: "1 گھنٹہ" - six_hours: "6 گھنٹے" - day: "24 گھنٹے" - custom: "ترمیم..." - hours: "گھنٹے" - usage: - ai_summarization: "خلاصہ" - ai_spam: "سپَیم" - next: - title: "اگلا" - tests: - success: "کامیابی!" - providers: - google: "گُوگَل" - fake: "اپنی مرضی کا" - ai_helper: - context_menu: - cancel: "منسوخ" - confirm: "تصدیق کریں" - discard: "منسوخ" - post_options_menu: - close: "بند کریں" - copy: "کاپی" - copied: "کاپی کر لیا!" - cancel: "منسوخ" - image_caption: - save_caption: "محفوظ کریں" - automatic_caption_dialog: - confirm: "فعال کریں" - embeddings: - back: "واپس" - save: "محفوظ کریں" - delete: "مٹائیں" - edit: "ترمیم کریں" - tests: - success: "کامیابی!" - display_name: "نام" - providers: - google: "گُوگَل" - fake: "اپنی مرضی کا" - ai_bot: - debug_ai_modal: - request: "ریکویسٹ" - response: "جواب" - next_log: "اگلا" - previous_log: "پچھلی" - invite_ai_conversation: - button: "دعوت دیں" - share_modal: - copy: "کاپی" - conversations: - today: "آج" - last_7_days: "پچھلے 7 دن" - last_30_days: "پچھلے 30 دن" - sentiments: - sentiment_analysis: - filter_types: - all: "تمام" - neutral: "نیوٹرل" - group_types: - category: "زمرہ" - tag: "ٹیگ" - table: - total_count: "کُل" - summarization: - chat: - summarize: "خلاصہ" - discobot_discoveries: - regular_results: "ٹاپک" - collapse: "بند کریں" - tooltip: - actions: - disable: "غیر فعال کریں" diff --git a/config/locales/client.vi.yml b/config/locales/client.vi.yml deleted file mode 100644 index 6216e0a0..00000000 --- a/config/locales/client.vi.yml +++ /dev/null @@ -1,182 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -vi: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "Sắp xếp theo" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "ID Chủ đề" - title: - label: "Tiêu đề" - categories: - label: "Danh mục" - tags: - label: "Thẻ" - llm_triage: - fields: - category: - label: "Danh mục" - tags: - label: "Thẻ" - canned_reply: - label: "Trả lời" - discourse_ai: - features: - back: "Quay lại" - disabled: "(vô hiệu hóa)" - groups: "Nhóm:" - no_groups: "Không có gì" - edit: "Sửa" - expand_list: - other: "(%{count} khác)" - filters: - all: "All" - reset: "Cài lại" - search: - name: "Tìm kiếm" - spam: - name: "Rác" - modals: - select_option: "Chọn một tùy Chọn..." - spam: - short_title: "Rác" - last_seven_days: "7 ngày qua" - enable: "Bật" - test_modal: - spam: "Rác" - usage: - summary: "Tóm tắt" - username: "Tên tài khoản" - total_requests: "Tổng số yêu cầu" - periods: - last_day: "24 giờ qua" - custom: "Tùy chỉnh..." - ai_persona: - back: "Quay lại" - name: "Tên" - edit: "Sửa" - export: "Xuất" - description: "Mô tả" - user: Người dùng - save: "Lưu lại" - enabled: "Kích hoạt" - delete: "Xóa" - response_format: - open_modal: "Sửa" - modal: - key_title: "Phím" - filters: - reset: "Cài lại" - rag: - uploads: - title: "Tải lên" - uploading: "Đang đăng " - tools: - back: "Quay lại" - export: "Xuất" - name: "Tên" - description: "Mô tả" - summary: "Tóm tắt" - save: "Lưu lại" - remove_parameter: "Xoá" - parameter_required: "Bắt buộc" - edit: "Sửa" - delete: "Xóa" - llms: - display_name: "Tên" - save: "Lưu lại" - edit: "Sửa" - back: "Quay lại" - delete: Xóa - quotas: - group: "Nhóm" - max_usages: "Sử dụng tối đa" - duration: "Thời lượng" - durations: - hour: "1 tiếng" - six_hours: "6 tiếng" - day: "24 tiếng" - week: "7 ngày" - custom: "Tùy chỉnh..." - hours: "tiếng" - usage: - ai_summarization: "Tóm tắt" - ai_spam: "Rác" - next: - title: "Kế tiếp" - tests: - success: "Thành công!" - providers: - google: "G" - fake: "Tùy biến" - ai_helper: - context_menu: - cancel: "Huỷ" - confirm: "Xác nhận" - discard: "Hủy bỏ" - post_options_menu: - close: "Đóng" - copy: "Sao chép" - copied: "Đã sao chép!" - cancel: "Huỷ" - thumbnail_suggestions: - select: "Chọn" - image_caption: - save_caption: "Lưu lại" - automatic_caption_dialog: - confirm: "Bật" - embeddings: - back: "Quay lại" - save: "Lưu lại" - delete: "Xóa" - edit: "Sửa" - tests: - success: "Thành công!" - display_name: "Tên" - providers: - google: "G" - fake: "Tùy biến" - ai_bot: - debug_ai_modal: - request: "Yêu cầu" - response: "Đáp ứng" - next_log: "Kế tiếp" - previous_log: "Trước" - invite_ai_conversation: - button: "Mời" - share_modal: - copy: "Sao chép" - conversations: - today: "Hôm nay" - last_7_days: "7 ngày qua" - last_30_days: "30 ngày qua" - sentiments: - sentiment_analysis: - filter_types: - all: "All" - group_types: - category: "Danh mục" - table: - total_count: "Tổng số" - summarization: - chat: - summarize: "Tóm tắt" - discobot_discoveries: - regular_results: "Các chủ đề" - collapse: "Thu gọn" - tooltip: - actions: - disable: "Tắt" diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml deleted file mode 100644 index e9be30f5..00000000 --- a/config/locales/client.zh_CN.yml +++ /dev/null @@ -1,704 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -zh_CN: - admin_js: - admin: - api: - scopes: - descriptions: - discourse_ai: - search: "允许 AI 搜索" - stream_completion: "允许流式 AI 角色补全" - update_personas: "允许更新 AI 角色模型" - site_settings: - categories: - discourse_ai: "Discourse AI" - dashboard: - emotion: - title: "情绪" - description: "该表列出了按确定的情绪分类的帖子数量。使用“SamLowe/roberta-base-go_emotions”模型进行分类。" - reports: - filters: - group_by: - label: "按…分组" - sort_by: - label: "排序依据" - tag: - label: "标签" - js: - discourse_automation: - scriptables: - llm_report: - fields: - sender: - label: "发送人" - description: "将发送报告的用户" - receivers: - label: "接收人" - description: "将收到报告的用户(电子邮件地址将收到直接电子邮件,用户名将收到私信)" - topic_id: - label: "话题 ID" - description: "要将报告发布到的话题 ID" - title: - label: "标题" - description: "报告的标题" - days: - label: "天数" - description: "报告的时间范围" - offset: - label: "偏移" - description: "在测试时,您可能希望运行历史报告,请使用“偏移”启动较早日期的报告" - instructions: - label: "指令" - description: "提供给大语言模型的指令" - sample_size: - label: "样本大小" - description: "要为报告抽样的帖子数" - tokens_per_post: - label: "每个帖子的词元数" - description: "每个帖子要使用的 LLM 词元数" - model: - label: "模型" - description: "用于生成报告的 LLM" - categories: - label: "类别" - description: "仅筛选这些类别的话题" - tags: - label: "标签" - description: "仅筛选这些标签的话题" - exclude_tags: - label: "排除标签" - description: "排除带有这些标签的话题" - exclude_categories: - label: "排除类别" - description: "排除这些类别的话题" - allow_secure_categories: - label: "允许安全类别" - description: "允许为安全类别中的话题生成报告" - suppress_notifications: - label: "禁止通知" - description: "通过转换为内容来禁止报告可能生成的通知。这将重新映射提及和内部链接。" - debug_mode: - label: "调试模式" - description: "启用调试模式以查看 LLM 的原始输入和输出" - priority_group: - label: "优先群组" - description: "在报告中优先考虑来自此群组的内容" - temperature: - label: "温度" - description: "用于 LLM 的温度值,增加该值会提高生成内容的随机性(留空则使用模型默认值)" - top_p: - label: "Top P" - description: "用于 LLM 的 Top P 值,增加该值会提高生成内容的随机性(留空则使用模型默认值)" - llm_tool_triage: - fields: - model: - label: "模型" - description: "用于初步分类的默认语言模型" - tool: - label: "工具" - description: "用于初步分类的工具(工具不得包含已定义的参数)" - llm_persona_triage: - fields: - persona: - label: "角色模型" - description: "用于初步分类的 AI 角色模型(必须设置默认 LLM 和用户)" - whisper: - label: "以 Whisper 模式回复" - llm_triage: - fields: - system_prompt: - label: "系统提示" - description: "将用于分类的提示,请确保它回复一个您可用于触发操作的字词" - max_post_tokens: - label: "帖子最大词元数" - description: "使用 LLM 分类扫描的最大词元数" - stop_sequences: - label: "停止序列" - description: "指示模型在达到其中一个值时停止生成词元" - search_for_text: - label: "搜索文本" - description: "如果 LLM 回复中出现以下文本,则应用这些操作" - category: - label: "类别" - description: "应用于话题的类别" - tags: - label: "标签" - description: "应用于话题的标签" - canned_reply: - label: "回复" - description: "要为话题发布的预设回复的原始文本" - canned_reply_user: - label: "回复用户" - description: "发布预设回复的用户的用户名" - hide_topic: - label: "隐藏话题" - description: "如果触发,则将话题设为对公众不可见" - flag_type: - label: "举报类型" - description: "对帖子应用的举报类型(垃圾内容或只是提出审核请求)" - flag_post: - label: "举报帖子" - description: "举报帖子(垃圾内容或请求审核)" - include_personal_messages: - label: "包括私信" - description: "同时扫描私信并对其进行分类" - model: - label: "模型" - description: "用于分类的语言模型" - temperature: - label: "温度" - discourse_ai: - title: "AI" - features: - back: "返回" - disabled: "(已禁用)" - persona: - other: "角色:" - groups: "群组:" - llm: - other: "LLM:" - no_persona: "未设置" - no_groups: "无" - edit: "编辑" - expand_list: - other: "(%{count})" - collapse_list: "(收起)" - filters: - all: "所有" - reset: "重置" - search: - name: "搜索" - embeddings: - name: "嵌入向量" - ai_helper: - name: "助手" - proofread: 审校文本 - explain: "解释" - smart_dates: "智能日期" - markdown_tables: "生成 Markdown 表" - custom_prompt: "自定义提示" - spam: - name: "垃圾内容" - description: "使用所选 LLM 识别潜在垃圾内容,并进行标记,使其进入审核队列,以便网站版主可以进行检查" - modals: - select_option: "选择一个选项…" - spam: - short_title: "垃圾内容" - title: "配置垃圾内容处理" - select_llm: "选择 LLM" - custom_instructions: "自定义指令" - custom_instructions_help: "特定于您的网站的自定义指令,帮助指导 AI 识别垃圾内容,例如“更积极地扫描非英语帖子”。" - last_seven_days: "过去 7 天" - scanned_count: "已扫描的帖子" - false_positives: "错误举报" - false_negatives: "错过的垃圾内容" - spam_detected: "检测到垃圾内容" - custom_instructions_placeholder: "针对特定网站的指令,帮助 AI 更准确地识别垃圾内容" - enable: "启用" - spam_tip: "AI 垃圾内容检测功能会扫描所有新用户在公开话题中发布的前 3 个帖子。如果这些帖子可能是垃圾内容,它会标记这些帖子以供审核并屏蔽这些用户。" - settings_saved: "设置已保存" - spam_description: "使用所选 LLM 识别潜在垃圾内容,并进行标记,使其进入审核队列,以便网站版主可以进行检查" - no_llms: "无可用 LLM" - test_button: "测试…" - save_button: "保存更改" - test_modal: - title: "测试垃圾内容检测" - post_url_label: "帖子 URL 或 ID" - post_url_placeholder: "https://your-forum.com/t/topic/123/4 或帖子 ID" - result: "结果" - scan_log: "扫描日志" - run: "运行测试" - spam: "垃圾内容" - not_spam: "非垃圾内容" - stat_tooltips: - incorrectly_flagged: "AI 机器人标记为垃圾内容但版主不同意的条目" - missed_spam: "被社区标记为垃圾内容但未被 AI 机器人检测到且版主认为是垃圾内容的条目" - errors: - scan_not_admin: - message: "警告:垃圾内容扫描无法正常工作,因为垃圾内容扫描帐户不是管理员帐户" - action: "修复" - resolved: "错误已解决!" - usage: - short_title: "使用量" - summary: "摘要" - total_tokens: "词元总数" - tokens_over_time: "一段时间内的词元数" - features_breakdown: "按功能的使用量" - feature: "功能" - usage_count: "使用次数" - model: "模型" - models_breakdown: "按模型的使用量" - users_breakdown: "按用户的使用量" - all_features: "所有功能" - all_models: "所有模型" - username: "用户名" - total_requests: "请求总数" - request_tokens: "请求词元" - response_tokens: "回答词元" - net_request_tokens: "网络请求词元" - cached_tokens: "缓存的词元" - cached_request_tokens: "缓存的请求词元" - no_users: "找不到用户使用数据" - no_models: "找不到模型使用数据" - no_features: "找不到功能使用数据" - stat_tooltips: - total_requests: "通过 Discourse 向 LLM 提出的所有请求" - total_tokens: "提示 LLM 时使用的所有词元" - request_tokens: "LLM 试图理解您所说的话时使用的词元" - response_tokens: "LLM 回答您的提示时使用的词元" - cached_tokens: "先前处理过的请求词元,LLM 会重复使用这些词元优化性能和成本" - periods: - last_day: "过去 24 小时" - last_week: "上周" - last_month: "上个月" - custom: "自定义…" - ai_persona: - ai_tools: "工具" - tool_strategies: - all: "应用于所有回复" - replies: - other: "应用于前 %{count} 条回复" - back: "返回" - name: "名称" - edit: "编辑" - export: "导出" - description: "描述" - no_llm_selected: "未选择语言模型" - max_context_posts: "最大上下文帖子数" - max_context_posts_help: "AI 在回复用户时用作上下文的最大帖子数。(默认为空)" - vision_enabled: 启用视觉 - vision_enabled_help: 如果启用,AI 将尝试理解用户在话题中发布的图片,这取决于使用的模型是否支持视觉。Anthropic、Google 和 OpenAI 的最新模型支持该功能。 - vision_max_pixels: 支持的图片大小 - vision_max_pixel_sizes: - low: 低品质 - 最便宜 (256x256) - medium: 中等品质 (512x512) - high: 高品质 - 最慢 (1024x1024) - tool_details: 显示工具详细信息 - tool_details_help: 将向最终用户显示语言模型触发了哪些工具的详细信息。 - mentionable: 允许提及 - mentionable_help: 如果启用,允许的群组中的用户可以在帖子中提及此用户,AI 将以此角色的身份进行回复。 - user: 用户 - create_user: 创建用户 - create_user_help: 您可以选择为此角色附加一个用户。如果这样做,AI 将使用此用户来回复请求。 - default_llm: 默认语言模型 - default_llm_help: 用于此角色的默认语言模型。如果您希望在公开帖子中提及该角色,则为必选项。 - question_consolidator_llm: 问题整合器的语言模型 - question_consolidator_llm_help: 用于问题整合器的语言模型,您可以选择功能较弱的模型来节省成本。 - system_prompt: 系统提示 - forced_tool_strategy: 强制工具策略 - allow_chat_direct_messages: "允许聊天直接消息" - allow_chat_direct_messages_help: "如果启用,允许的群组中的用户可以向此角色发送直接消息。" - allow_chat_channel_mentions: "允许聊天频道提及" - allow_chat_channel_mentions_help: "如果启用,允许的群组中的用户可以在聊天频道中提及此角色。" - allow_personal_messages: "允许私信" - allow_personal_messages_help: "如果启用,允许的群组中的用户可以向此角色发送私信。" - allow_topic_mentions: "允许话题提及" - allow_topic_mentions_help: "如果启用,允许的群组中的用户可以在话题中提及此角色。" - force_default_llm: "始终使用默认语言模型" - save: "保存" - saved: "角色已保存" - enabled: "已启用?" - tools: "启用的工具" - forced_tools: "强制工具" - allowed_groups: "允许的群组" - confirm_delete: "确定要删除此角色吗?" - new: "新角色" - no_personas: "您还没有创建任何角色" - title: "角色" - short_title: "角色" - delete: "删除" - temperature: "温度" - temperature_help: "用于 LLM 的温度。增大该值可以提升创造力(留空将使用模型默认值,通常为 0.0 到 2.0 之间的值)" - top_p: "Top P" - top_p_help: "用于 LLM 的 Top P,增大可以提升随机性(留空将使用模型默认值,通常为 0.0 到 1.0 之间的值)" - priority: "优先" - priority_help: "优先角色会在角色列表的顶部向用户显示。如果多个角色都具有优先级,将按字母顺序排序。" - tool_options: "工具选项" - rag_conversation_chunks: "搜索对话分块" - rag_conversation_chunks_help: "为 RAG 模型搜索使用的分块数。增加分块数会增加 AI 可以使用的上下文数量。" - persona_description: "角色是一种强大的功能,可以让您自定义 Discourse 论坛中 AI 引擎的行为。它们充当“系统消息”,指导 AI 的回答和互动,帮助创造更加个性化、引人入胜的用户体验。" - response_format: - open_modal: "编辑" - modal: - key_title: "键" - list: - enabled: "AI 机器人?" - ai_bot: - title: "AI 机器人选项" - filters: - reset: "重置" - rag: - options: - rag_chunk_tokens: "上传分块词元" - rag_chunk_tokens_help: "RAG 模型中为每个分块使用的词元数。增大词元数会增加 AI 可以使用的上下文数量。(更改词元数将为所有上传内容重新编制索引)" - rag_chunk_overlap_tokens: "上传分块重叠词元" - rag_chunk_overlap_tokens_help: "RAG 模型中分块之间重叠的词元数。(更改词元数将为所有上传内容重新编制索引)" - show_indexing_options: "显示上传选项" - hide_indexing_options: "隐藏上传选项" - uploads: - title: "上传" - button: "添加文件" - filter: "筛选上传" - indexed: "已编制索引" - indexing: "正在编制索引" - uploaded: "准备好编制索引" - uploading: "正在上传…" - remove: "移除上传" - tools: - back: "返回" - short_title: "工具" - export: "导出" - no_tools: "您还没有创建任何工具" - name: "名称" - new: "新工具" - description: "描述" - description_help: "向语言模型介绍工具用途的清晰描述" - subheader_description: "工具可以通过用户定义的 JavaScript 函数扩展 AI 机器人的功能。" - summary: "摘要" - summary_help: "显示给最终用户的工具用途摘要" - script: "脚本" - parameters: "参数" - save: "保存" - remove_parameter: "移除" - parameter_required: "必选" - parameter_enum: "枚举" - parameter_name: "参数名称" - parameter_description: "参数描述" - enum_value: "枚举值" - add_enum_value: "添加枚举值" - edit: "编辑" - test: "运行测试" - delete: "删除" - saved: "工具已保存" - confirm_delete: "确定要删除此工具吗?" - test_modal: - title: "测试 AI 工具" - run: "运行测试" - result: "测试结果" - llms: - short_title: "LLM" - no_llms: "没有 LLM" - new: "新模型" - display_name: "名称" - name: "模型 ID" - provider: "提供程序" - tokenizer: "词元生成器" - url: "托管模型的服务的 URL" - api_key: "托管模型的服务的 API 密钥" - enabled_chat_bot: "允许 AI 机器人选择器" - vision_enabled: "启用视觉" - ai_bot_user: "AI 机器人用户" - save: "保存" - edit: "编辑" - saved: "LLM 模型已保存" - back: "返回" - confirm_delete: 确定要删除此模型吗? - delete: 删除 - seeded_warning: "此模型已在您的网站上预先配置,无法编辑。" - quotas: - title: "使用配额" - add_title: "创建新配额" - group: "群组" - max_tokens: "最大词元数" - max_usages: "最大使用次数" - duration: "持续时间" - confirm_delete: "确定要删除此配额吗?" - add: "添加配额" - durations: - hour: "1 小时" - six_hours: "6 小时" - day: "24 小时" - week: "7 天" - custom: "自定义…" - hours: "小时" - max_tokens_help: "此群组中每个用户在指定时长内可以使用的最大词元(单词和字符)数。词元是 AI 模型处理文本时使用的单位,1 个词元约等于 4 个字符或 3/4 个单词。" - max_usages_help: "此群组中每个用户在指定时长内可以使用 AI 模型的最大次数。此配额按个别用户进行跟踪,不在整个群组中共享。" - usage: - ai_bot: "AI 机器人" - ai_helper: "助手" - ai_persona: "角色 (%{persona})" - ai_summarization: "总结" - ai_embeddings_semantic_search: "AI 搜索" - ai_spam: "垃圾内容" - in_use_warning: - other: "此模型目前的使用者为 %{settings}。如果配置错误,功能将无法按预期运行。" - model_description: - none: "适用于大多数语言模型的常规设置" - anthropic-claude-opus-4-0: "Anthropic 最聪明的模型" - anthropic-claude-3-5-haiku-latest: "快速、经济实惠" - google-gemini-2-5-flash: "轻量、快速、经济高效,具有多模态推理能力" - samba_nova-Meta-Llama-3-1-8B-Instruct: "高效的轻量级多语言模型" - samba_nova-Meta-Llama-3-3-70B-Instruct": "强大的多用途模型" - mistral-mistral-large-latest: "Mistral 最强大的模型" - mistral-pixtral-large-latest: "Mistral 最强大的视觉能力模型" - preseeded_model_description: "利用 %{model} 的预配置开源模型" - configured: - title: "配置的 LLM" - preconfigured_llms: "选择您的 LLM" - preconfigured: - title_no_llms: "选择一个模板以开始" - title: "未配置的 LLM 模板" - fake: "手动配置" - button: "设置" - next: - title: "下一步" - tests: - title: "运行测试" - running: "正在运行测试…" - success: "成功!" - failure: "尝试联系模型时返回此错误:%{error}" - hints: - name: "我们将其包含在 API 调用中以指定我们将使用哪个模型" - vision_enabled: "如果启用,AI 将尝试理解图片,这取决于使用的模型是否支持视觉。Anthropic、Google 和 OpenAI 的最新模型支持该功能。" - enabled_chat_bot: "如果启用,用户可以在使用 AI 机器人创建私信时选择此模型" - providers: - aws_bedrock: "AWS Bedrock" - anthropic: "Anthropic" - vllm: "vLLM" - hugging_face: "Hugging Face" - cohere: "Cohere" - open_ai: "OpenAI" - google: "Google" - azure: "Azure" - ollama: "Ollama" - CDCK: "CDCK" - samba_nova: "SambaNova" - mistral: "Mistral" - open_router: "OpenRouter" - fake: "自定义" - provider_fields: - access_key_id: "AWS Bedrock 访问密钥 ID" - region: "AWS Bedrock 区域" - organization: "可选 OpenAI 组织 ID" - disable_system_prompt: "在提示中禁用系统消息" - enable_native_tool: "启用原生工具支持" - disable_native_tools: "禁用原生工具支持(使用基于 XML 的工具)" - provider_order: "提供程序顺序(逗号分隔列表)" - provider_quantizations: "提供程序量化顺序(逗号分隔列表,例如:fp16,fp8)" - disable_streaming: "禁用流式补全(将流式请求转换为非流式请求)" - related_topics: - title: "相关话题" - pill: "相关" - ai_helper: - title: "使用 AI 提出更改建议" - description: "选择以下选项之一,AI 将向您推荐新版本文本。" - selection_hint: "提示:您也可以在打开助手之前选择文本的一部分来仅重写该文本。" - suggest: "通过 AI 提出建议" - suggest_errors: - too_many_tags: - other: "您最多只能有 %{count} 个标签" - no_suggestions: "没有建议" - missing_content: "请输入一些内容以生成建议。" - context_menu: - trigger: "询问 AI" - loading: "AI 正在生成" - cancel: "取消" - confirm: "确认" - discard: "舍弃" - changes: "建议的编辑" - custom_prompt: - title: "自定义提示" - placeholder: "输入自定义提示…" - submit: "发送提示" - translate_prompt: "翻译为%{language}" - post_options_menu: - trigger: "询问 AI" - title: "询问 AI" - loading: "AI 正在生成" - close: "关闭" - copy: "复制" - copied: "已复制!" - cancel: "取消" - insert_footnote: "添加脚注" - footnote_disabled: "自动插入功能已禁用,请点击“复制”按钮并手动编辑" - footnote_credits: "AI 的解释" - fast_edit: - suggest_button: "建议编辑" - thumbnail_suggestions: - title: "建议的缩略图" - select: "选择" - selected: "已选择" - image_caption: - button_label: "使用 AI 生成标题" - generating: "正在生成标题…" - credits: "由 AI 生成标题" - save_caption: "保存" - automatic_caption_setting: "启用自动标题" - automatic_caption_loading: "正在为图片生成标题…" - automatic_caption_dialog: - prompt: "此帖子包含无标题图片。要在上传图片时启用自动生成标题功能吗?(稍后可以在偏好设置中进行更改)" - confirm: "启用" - cancel: "不再询问" - no_content_error: "先添加内容,然后对其执行 AI 操作" - reviewables: - model_used: "使用的模型:" - accuracy: "准确性:" - embeddings: - short_title: "嵌入向量" - new: "新建嵌入向量" - back: "返回" - save: "保存" - saved: "嵌入向量配置已保存" - delete: "删除" - confirm_delete: 确定要移除此嵌入向量配置吗? - empty: "您尚未设置嵌入向量" - presets: "选择一个预设…" - configure_manually: "手动配置" - edit: "编辑" - seeded_warning: "此内容在您的网站上预先配置,无法编辑。" - tests: - title: "运行测试" - running: "正在运行测试…" - success: "成功!" - failure: "尝试生成嵌入向量导致以下错误:%{error}" - hints: - dimensions_warning: "保存后,此值将无法更改。" - matryoshka_dimensions: "定义用于分层或多层数据表示的嵌套嵌入向量的大小,类似于套娃相互契合的方式。" - sequence_length: "创建嵌入向量或处理查询时一次可以处理的最大词元数。" - distance_function: "确定如何计算嵌入向量之间的相似度,可以使用余弦距离(测量向量的夹角)或负内积(测量向量值的重叠)。" - display_name: "名称" - provider: "提供程序" - url: "嵌入向量服务 URL" - api_key: "嵌入向量服务 API 密钥" - tokenizer: "分词器" - dimensions: "嵌入向量尺寸" - max_sequence_length: "序列长度" - embed_prompt: "嵌入提示" - search_prompt: "搜索提示" - matryoshka_dimensions: "套娃尺寸" - distance_function: "距离函数" - distance_functions: - "<#>": "负内积" - <=>: "余弦距离" - providers: - hugging_face: "Hugging Face" - open_ai: "OpenAI" - google: "Google" - cloudflare: "Cloudflare" - CDCK: "CDCK" - fake: "自定义" - provider_fields: - model_name: "模型名称" - semantic_search: "话题(语义)" - semantic_search_loading: "正在使用 AI 搜索更多结果" - semantic_search_results: - toggle: "显示使用 AI 找到的 %{count} 个结果" - toggle_hidden: "隐藏使用 AI 找到的 %{count} 个结果" - none: "抱歉,我们的 AI 搜索没有找到匹配的话题" - new: "按“搜索”开始使用 AI 查找新结果" - unavailable: "AI 结果不可用" - semantic_search_tooltips: - results_explanation: "启用后,将在下方添加更多 AI 搜索结果。" - invalid_sort: "搜索结果必须按相关性排序才能显示 AI 结果" - semantic_search_unavailable_tooltip: "搜索结果必须按相关性排序才能显示 AI 结果" - ai_generated_result: "使用 AI 找到的搜索结果" - quick_search: - suffix: "在所有话题和帖子中使用 AI 搜索" - ai_artifact: - expand_view_label: "扩展视图" - collapse_view_label: "退出全屏(按 ESC 或“返回”按钮)" - click_to_run_label: "运行工件" - ai_bot: - persona: "角色模型" - llm: "模型" - pm_warning: "版主会定期监控 AI 聊天机器人消息。" - cancel_streaming: "停止回复" - default_pm_prefix: "[无标题 AI 机器人私信]" - shortcut_title: "使用 AI 机器人启动私信" - share: "复制 AI 对话" - conversation_shared: "对话已复制" - debug_ai: "查看原始 AI 请求和回答" - debug_ai_modal: - title: "查看 AI 交互" - copy_request: "复制请求" - copy_response: "复制回答" - request_tokens: "请求令牌:" - response_tokens: "回答令牌:" - request: "请求" - response: "回答" - next_log: "下一步" - previous_log: "上一步" - share_full_topic_modal: - title: "公开分享对话" - share: "分享并复制链接" - update: "更新并复制链接" - delete: "删除分享" - share_ai_conversation: - name: "分享 AI 对话" - title: "公开分享此 AI 对话" - invite_ai_conversation: - button: "邀请" - ai_label: "AI" - ai_title: "与 AI 的对话" - share_modal: - title: "复制 AI 对话" - copy: "复制" - context: "要分享的互动:" - share_tip: "或者,您可以分享整个对话" - bot_names: - fake: "假测试机器人" - claude-3-opus: "Claude 3 Opus" - claude-3-sonnet: "Claude 3 Sonnet" - claude-3-haiku: "Claude 3 Haiku" - cohere-command-r-plus: "Cohere Command R Plus" - gpt-4: "GPT-4" - gpt-4-turbo: "GPT-4 Turbo" - gpt-4o: "GPT-4 Omni" - gpt-3: - 5-turbo: "GPT-3.5" - claude-2: "Claude 2" - gemini-1: - 5-pro: "Gemini" - mixtral-8x7B-Instruct-V0: - "1": "Mixtral-8x7B V0.1" - conversations: - today: "今天" - last_7_days: "过去 7 天" - last_30_days: "过去 30 天" - sentiments: - dashboard: - title: "情绪" - sentiment_analysis: - filter_types: - all: "所有" - positive: "积极" - neutral: "中性" - negative: "消极" - group_types: - category: "类别" - tag: "标签" - table: - sentiment: "情绪" - total_count: "总计" - summarization: - chat: - title: "总结消息" - description: "选择以下选项,以总结在所需时间范围内发送的对话。" - summarize: "总结" - since: - other: "过去 %{count} 小时" - topic: - title: "话题摘要" - close: "关闭摘要面板" - topic_list_layout: - button: - compact: "紧凑" - expanded: "展开" - expanded_description: "带 AI 摘要" - discobot_discoveries: - regular_results: "话题" - collapse: "收起" - tooltip: - actions: - disable: "禁用" - review: - types: - reviewable_ai_post: - title: "AI 举报的帖子" - reviewable_ai_chat_message: - title: "AI 举报的聊天消息" diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml deleted file mode 100644 index bd758e26..00000000 --- a/config/locales/client.zh_TW.yml +++ /dev/null @@ -1,187 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -zh_TW: - admin_js: - admin: - dashboard: - reports: - filters: - sort_by: - label: "排序" - tag: - label: "標記" - js: - discourse_automation: - scriptables: - llm_report: - fields: - topic_id: - label: "話題 ID" - title: - label: "標題" - categories: - label: "分類" - tags: - label: "標記" - llm_triage: - fields: - category: - label: "分類" - tags: - label: "標記" - canned_reply: - label: "回覆" - discourse_ai: - features: - back: "上一步" - disabled: "(停用)" - groups: "群組:" - no_persona: "未設定" - no_groups: "無" - edit: "編輯" - expand_list: - other: "(%{count} 更多)" - collapse_list: "(顯示更少)" - filters: - all: "全部" - reset: "重置" - search: - name: "搜尋" - spam: - name: "垃圾內容" - spam: - short_title: "垃圾內容" - last_seven_days: "過去 7 天" - enable: "啟用" - test_modal: - spam: "垃圾內容" - usage: - summary: "摘要" - username: "使用者名稱" - total_requests: "請求總數" - periods: - last_day: "過去 24 小時" - custom: "自訂..." - ai_persona: - back: "上一步" - name: "名字" - edit: "編輯" - export: "匯出" - description: "簡述" - user: 使用者 - save: "保存" - enabled: "啟用?" - delete: "刪除" - response_format: - open_modal: "編輯" - modal: - key_title: "金鑰" - filters: - reset: "重置" - rag: - uploads: - title: "上傳檔案" - uploading: "上傳中..." - tools: - back: "上一步" - export: "匯出" - name: "名字" - description: "簡述" - summary: "摘要" - save: "保存" - remove_parameter: "移除" - parameter_required: "必要設定" - edit: "編輯" - delete: "刪除" - llms: - display_name: "名字" - save: "保存" - edit: "編輯" - back: "上一步" - delete: 刪除 - quotas: - group: "群組" - duration: "持續時間" - durations: - hour: "1 小時" - six_hours: "6 小時" - day: "24 小時" - custom: "自訂..." - hours: "小時" - usage: - ai_summarization: "總結" - ai_spam: "垃圾內容" - next: - title: "下一步" - tests: - success: "成功!" - providers: - google: "Google" - fake: "客製" - ai_helper: - context_menu: - cancel: "取消" - discard: "捨棄" - post_options_menu: - close: "關閉" - copy: "複製" - copied: "已複製!" - cancel: "取消" - thumbnail_suggestions: - select: "選擇" - selected: "已選" - image_caption: - save_caption: "保存" - automatic_caption_dialog: - confirm: "啟用" - embeddings: - back: "上一步" - save: "保存" - delete: "刪除" - edit: "編輯" - tests: - success: "成功!" - display_name: "名字" - providers: - google: "Google" - fake: "客製" - ai_bot: - debug_ai_modal: - request: "請求" - response: "回應" - next_log: "下一步" - previous_log: "舊" - invite_ai_conversation: - button: "邀請" - share_modal: - copy: "複製" - conversations: - today: "今天" - last_7_days: "過去 7 天" - last_30_days: "過去 30 天" - sentiments: - sentiment_analysis: - filter_types: - all: "全部" - neutral: "中性" - group_types: - category: "分類" - tag: "標記" - table: - total_count: "總數" - summarization: - chat: - title: "摘要訊息" - summarize: "總結" - since: - other: "前 %{count} 小時" - discobot_discoveries: - regular_results: "話題" - collapse: "收起" - tooltip: - actions: - disable: "禁用" diff --git a/config/locales/server.ar.yml b/config/locales/server.ar.yml deleted file mode 100644 index 9236d20d..00000000 --- a/config/locales/server.ar.yml +++ /dev/null @@ -1,480 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ar: - discourse_automation: - ai: - flag_types: - review: "إضافة المنشور إلى قائمة انتظار المراجعة" - spam: "وضع علامة \"عشوائي\" على المنشور وإخفاؤه" - spam_silence: "وضع علامة عشوائي على المنشور وإخفاؤه وكتم المستخدم" - scriptables: - llm_triage: - title: فرز المنشورات باستخدام الذكاء الاصطناعي - description: "فرز المنشورات باستخدام نموذج لغوي كبير" - flagged_post: | -
استجابة من النموذج:
-

%%LLM_RESPONSE%%

- تم تشغيله من قِبل %%AUTOMATION_NAME%% القاعدة. - llm_report: - title: التقرير الدوري باستخدام الذكاء الاصطناعي - description: "تقرير دوري قائم على نموذج لغوي كبير" - site_settings: - discourse_ai_enabled: "تفعيل المكوِّن الإضافي Discourse AI." - ai_toxicity_enabled: "تفعيل وحدة السلبية." - ai_toxicity_inference_service_api_endpoint: "عنوان URL حيث تعمل واجهة API لوحدة السلبية" - ai_toxicity_inference_service_api_key: "مفتاح API لواجهة API الخاصة بنموذج السلبية" - ai_toxicity_inference_service_api_model: "النموذج المراد استخدامه في الاستدلال. نموذج متعدد اللغات يعمل مع اللغة الإيطالية والفرنسية والروسية والبرتغالية والإسبانية والتركية." - ai_toxicity_flag_automatically: "الإبلاغ تلقائيًا عن المنشورات/رسائل الدردشة التي تتجاوز الحدود التي تم تكوينها." - ai_toxicity_flag_threshold_toxicity: "السلبية: تعليق فظ أو غير محترم أو غير معقول، والذي من المحتمل بعض الشيء أن يجعلك تغادر مناقشة أو تتخلى عن مشاركة وجهة نظرك" - ai_toxicity_flag_threshold_severe_toxicity: "السلبية الشديدة: تعليق شديد الكراهية أو هجومي أو غير محترم، والذي من المحتمل جدًا أن يجعلك تغادر مناقشة أو تتخلى عن مناقشة وجهة نظرك" - ai_toxicity_flag_threshold_obscene: "فاحش" - ai_toxicity_flag_threshold_identity_attack: "هجوم انتحال الشخصية" - ai_toxicity_flag_threshold_insult: "إهانة" - ai_toxicity_flag_threshold_threat: "تهديد" - ai_toxicity_flag_threshold_sexual_explicit: "محتوى جنسي صريح" - ai_toxicity_groups_bypass: "لن يتم تصنيف منشورات المستخدمين في هذه المجموعات حسب وحدة السلبية." - ai_sentiment_enabled: "تفعيل وحدة المشاعر." - ai_sentiment_inference_service_api_endpoint: "عنوان URL حيث تعمل واجهة API لوحدة المشاعر" - ai_sentiment_inference_service_api_key: "مفتاح API لواجهة API الخاصة بنموذج المشاعر" - ai_sentiment_models: "النماذج المراد استخدامها في الاستدلال. يصنِّف نموذج المشاعر المنشورات إلى إيجابية/محايدة/سلبية. ويصنِّفها نموذج العاطفة إلى غضب/اشمئزاز/خوف/فرح/حياد/حزن/مفاجأة." - ai_nsfw_detection_enabled: "تفعيل وحدة NSFW (غير آمن لبيئة العمل)." - ai_nsfw_inference_service_api_endpoint: "عنوان URL حيث تعمل واجهة API لوحدة NSFW (غير آمن لبيئة العمل)" - ai_nsfw_inference_service_api_key: "مفتاح API لواجهة API الخاصة بنموذج NSFW (غير آمن لبيئة العمل)" - ai_nsfw_flag_automatically: "الإبلاغ تلقائيًا عن المنشورات غير الآمنة للعمل التي تتجاوز الحدود التي تم تكوينها." - ai_nsfw_flag_threshold_general: "الحد العام للصورة ليتم اعتبارها غير آمنة لبيئة العمل." - ai_nsfw_flag_threshold_drawings: "الحد الأدنى للرسمة ليتم اعتبارها غير آمنة لبيئة العمل." - ai_nsfw_flag_threshold_hentai: "الحد الأدنى للصورة المصنَّفة كمحتوى \"هنتاي\" ليتم اعتبارها غير آمنة لبيئة العمل." - ai_nsfw_flag_threshold_porn: "الحد الأدنى للصورة المصنَّفة كمحتوى إباحي ليتم اعتبارها غير آمنة لبيئة العمل." - ai_nsfw_flag_threshold_sexy: "الحد الأدنى للصورة المصنَّفة كمحتوى جنسي ليتم اعتبارها غير آمنة لبيئة العمل." - ai_nsfw_models: "نماذج المراد استخدامها في الاستدلال على المحتوى غير الآمن لبيئة العمل." - ai_helper_enabled: "تفعيل مساعد الذكاء الاصطناعي" - composer_ai_helper_allowed_groups: "سيرى المستخدمون في هذه المجموعات زر مساعد الذكاء الاصطناعي في أداة الإنشاء." - ai_helper_allowed_in_pm: "تفعيل مساعد الذكاء الاصطناعي في الرسائل الخاصة." - ai_helper_model: "النموذج المراد استخدامه لمساعد الذكاء الاصطناعي." - ai_helper_custom_prompts_allowed_groups: "سيرى المستخدمون في هذه المجموعات خيار رسالة المطالبة المخصَّصة في مساعد الذكاء الاصطناعي." - ai_helper_automatic_chat_thread_title_delay: "التأخير بالدقائق قبل أن يقوم مساعد الذكاء الاصطناعي بتعيين عنوان سلسلة الدردشة تلقائيًا." - ai_helper_automatic_chat_thread_title: "تعيين عناوين سلسلة الدردشة تلقائيًا بناءً على محتويات السلسلة." - ai_helper_illustrate_post_model: "النموذج المراد استخدامه لميزة تزويد المنشور بالصور من مساعد الذكاء الاصطناعي في أداة الإنشاء" - ai_helper_enabled_features: "حدِّد الميزات التي تريد تفعيلها في مساعد الذكاء الاصطناعي." - post_ai_helper_allowed_groups: "مجموعات المستخدمين المسموح لها بالوصول إلى ميزات مساعد الذكاء الاصطناعي في المنشورات" - ai_helper_image_caption_model: "حدِّد النموذج الذي سيتم استخدامه لإنشاء التسميات التوضيحية للصور" - ai_auto_image_caption_allowed_groups: "يمكن للمستخدمين في هذه المجموعات تشغيل التسميات التوضيحية التلقائية للصور." - ai_embeddings_selected_model: "استخدم النموذج المُحدَّد لتوليد التضمينات." - ai_embeddings_generate_for_pms: "إنشاء تضمينات للرسائل الشخصية." - ai_embeddings_semantic_related_topics_enabled: "استخدام البحث الدلالي للموضوعات ذات الصلة." - ai_embeddings_semantic_related_topics: "أقصى عدد من الموضوعات لعرضها في قسم الموضوع ذي الصلة." - ai_embeddings_backfill_batch_size: "عدد التضمينات المراد إعادة ملئها كل 15 دقيقة." - ai_embeddings_semantic_search_enabled: "تفعيل البحث الدلالي في الصفحة كاملةً." - ai_embeddings_semantic_quick_search_enabled: "تفعيل خيار البحث الدلالي في قائمة البحث المنبثقة." - ai_embeddings_semantic_related_include_closed_topics: "تضمين الموضوعات المغلقة في نتائج البحث الدلالي" - ai_embeddings_semantic_search_hyde_model: "النموذج المُستخدَم لتوسيع الكلمات الرئيسية للحصول على نتائج أفضل في أثناء البحث الدلالي" - ai_embeddings_per_post_enabled: إنشاء التضمينات لكل منشور - ai_summarization_model: "النموذج الذي سيتم استخدامه في التلخيص" - ai_custom_summarization_allowed_groups: "المجموعات المسموح لها بإنشاء ملخصات جديدة." - ai_pm_summarization_allowed_groups: "المجموعات المسموح لها بإنشاء الملخصات وعرضها في الرسائل الخاصة" - ai_summary_gists_allowed_groups: "المجموعات المسموح لها برؤية ملخصات الموضوعات في قائمة الموضوعات الساخنة" - ai_summary_backfill_maximum_topics_per_hour: "عدد ملخصات الموضوعات المطلوب ملؤها في الساعة." - ai_bot_enabled: "تفعيل وحدة روبوت الذكاء الاصطناعي" - ai_bot_enable_chat_warning: "عرض تحذير عند بدء الدردشة في رسالة خاصة. يمكن تجاوزه عن طريق تعديل سلسلة الترجمة: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "عندما يتمكن GPT Bot من الوصول إلى الرسالة الخاصة، فإنه سيرد على أعضاء هذه المجموعات." - ai_bot_debugging_allowed_groups: "السماح لهذه المجموعات برؤية زر تصحيح الأخطاء في المنشورات التي تعرض طلب الذكاء الاصطناعي الأولي والرد" - ai_bot_public_sharing_allowed_groups: "السماح لهذه المجموعات بمشاركة رسائل الذكاء الاصطناعي الشخصية مع العامة عبر رابط فريد متاح بشكلٍ علني. ملاحظة: إذا كان موقعك يتطلَّب تسجيل الدخول، فستتطلب المشاركة تسجيل الدخول أيضًا." - ai_bot_add_to_header: "عرض زر في الرأس لبدء رسالة خاصة مع روبوت ذكاء اصطناعي" - ai_bot_github_access_token: "رمز وصول GitHub للاستخدام مع أدوات الذكاء الاصطناعي من GitHub (مطلوب لدعم البحث)" - ai_stability_api_key: "مفتاح API لواجهة API المسماة stability.ai" - ai_stability_engine: "محرك إنشاء الصور المراد استخدامه لواجهة برمجة التطبيقات المسماة stability.ai" - ai_stability_api_url: "عنوان URL لواجهة API المسماة stability.ai" - ai_google_custom_search_api_key: "مفتاح API لواجهة API الخاصة ببحث Google المخصَّص، راجع: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "تجربة العملاء لواجهة بحث Google المخصَّصة" - reviewables: - reasons: - flagged_by_toxicity: أبلغ المكوِّن الإضافي الذي يعمل بالذكاء الاصطناعي عن هذا بعد تصنيفه كسلبي. - flagged_by_nsfw: أبلغ المكوِّن الإضافي الذي يعمل بالذكاء الاصطناعي عن هذا بعد تصنيفه كسلبي لصورة واحدة على الأقل من الصور الملحقة على أنها غير آمنة لبيئة العمل. - reports: - overall_sentiment: - title: "المشاعر العامة" - description: 'يقارن الرسم البياني عدد المنشورات المصنَّفة على أنها إما إيجابية أو سلبية. يتم حساب هذه النتائج عندما تكون الدرجات الإيجابية أو السلبية > درجة الحد المحدَّدة. وهذا يعني عدم عرض المنشورات المحايدة. يتم أيضًا استبعاد الرسائل الخاصة (PM). يتم تصنيفها باستخدام "cardiffnlp/twitter-roberta-base-sentiment-latest"' - xaxis: "إيجابية (%)" - yaxis: "التاريخ" - emotion_admiration: - title: "\U0001F929 الإعجاب" - description: "المنشورات المصنَّفة برمز الإعجاب عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_amusement: - title: "\U0001F604 التسلية" - description: "المنشورات المصنَّفة برمز التسلية عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_anger: - title: "\U0001F620 الغضب" - description: "المنشورات المصنَّفة برمز الغضب عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_annoyance: - title: "\U0001F612 الإزعاج" - description: "المنشورات المصنَّفة برمز الإزعاج عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_approval: - title: "\U0001F44D الموافقة" - description: "المنشورات المصنَّفة برمز الموافقة عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_caring: - title: "\U0001F917 الاهتمام" - description: "المنشورات المصنَّفة برمز الاهتمام عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_confusion: - title: "\U0001F615 الارتباك" - description: "المنشورات المصنَّفة برمز الارتباك عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_curiosity: - title: "\U0001F914 الفضول" - description: "المنشورات المصنَّفة برمز الفضول عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_desire: - title: "\U0001F60D الرغبة" - description: "المنشورات المصنَّفة برمز الرغبة عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_disappointment: - title: "\U0001F61E خيبة الأمل" - description: "المنشورات المصنَّفة برمز خيبة الأمل عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_disapproval: - title: "\U0001F44E عدم الموافقة" - description: "المنشورات المصنَّفة برمز عدم الموافقة عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_disgust: - title: "\U0001F922 الاشمئزاز" - description: "المنشورات المصنَّفة برمز الاشمئزاز عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_embarrassment: - title: "\U0001F633 الإحراج" - description: "المنشورات المصنَّفة برمز الإحراج عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_excitement: - title: "\U0001F92A الإثارة" - description: "المنشورات المصنَّفة برمز الإثارة عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_fear: - title: "\U0001F628 الخوف" - description: "المنشورات المصنَّفة برمز الخوف عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_gratitude: - title: "\U0001F64F الامتنان" - description: "المنشورات المصنَّفة برمز الامتنان عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_grief: - title: "\U0001F622 الكآبة" - description: "المنشورات المصنَّفة برمز الكآبة عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_joy: - title: "\U0001F60A المرح" - description: "المنشورات المصنَّفة برمز الفرح عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_love: - title: '❤️ الحب' - description: "المنشورات المصنَّفة برمز الحب عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_nervousness: - title: "\U0001F630 التوتر" - description: "المنشورات المصنَّفة برمز التوتر عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_neutral: - title: "\U0001F610 محايدة" - description: "المنشورات المصنَّفة برمز الحياد عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_optimism: - title: "\U0001F31F التفاؤل" - description: "المنشورات المصنَّفة برمز التفاؤل عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_pride: - title: "\U0001F981 الفخر" - description: "المنشورات المصنَّفة برمز الفخر عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_realization: - title: "\U0001F4A1 الإدراك" - description: "المنشورات المصنَّفة برمز الإدراك عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_relief: - title: "\U0001F60C الراحة" - description: "المنشورات المصنَّفة برمز الراحة عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_remorse: - title: "\U0001F614 الندم" - description: "المنشورات المصنَّفة برمز الندم عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_sadness: - title: "\U0001F62D الحزن" - description: "المنشورات المصنَّفة برمز الحزن عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - emotion_surprise: - title: "\U0001F632 المفاجأة" - description: "المنشورات المصنَّفة برمز المفاجأة عن طريق الذكاء الاصطناعي، باستخدام نموذج \"Samlowe/roberta-base-go_emotions\"" - discourse_ai: - ai_artifact: - view_source: "عرض المصدر" - view_changes: "عرض التغييرات" - unknown_model: "نموذج ذكاء اصطناعي غير معروف" - tools: - custom_name: "%{name} (custom)" - presets: - browse_web_jina: - name: "تصفح الويب (jina.ai)" - exchange_rate: - name: "سعر الصرف" - stock_quote: - name: "أسعار الأسهم (AlphaVantage)" - image_generation: - name: "مولِّد صور Flux (Together.ai)" - empty_tool: - name: "ابدأ من لا شيء..." - ai_helper: - errors: - completion_request_failed: "حدث خطأ في أثناء محاولة تقديم اقتراحات. يُرجى إعادة المحاولة." - prompts: - translate: الترجمة إلى %{language} - generate_titles: اقتراح عناوين للموضوعات - proofread: تدقيق النص لغويًا - markdown_table: إنشاء جدول Markdown - custom_prompt: "رسالة مطالبة مخصَّصة" - explain: "الشرح" - illustrate_post: "تزويد المنشور بالصور" - replace_dates: "التواريخ الذكية" - painter: - attribution: - stable_diffusion_xl: "الصورة بواسطة Stable Diffusion XL" - dall_e_3: "الصورة بواسطة DALL-E 3" - image_caption: - attribution: "تم إنشاء التسمية التوضيحية بالذكاء الاصطناعي" - share_ai: - read_more: "قراءة النص الكامل" - onebox_title: "محادثة الذكاء الاصطناعي مع %{llm_name}" - formatted_excerpt: "محادثة الذكاء الاصطناعي مع %{llm_name}:\n %{excerpt}" - title: "%{title} - محادثة الذكاء الاصطناعي - %{site_name}" - errors: - not_allowed: "غير مسموح لك بمشاركة هذا الموضوع" - other_people_in_pm: "لا يمكن مشاركة الرسائل الشخصية مع البشر الآخرين بشكلٍ علني" - other_content_in_pm: "لا يمكن مشاركة الرسائل الشخصية التي تحتوي على منشورات من أشخاص آخرين بشكلٍ علني" - failed_to_share: "فشلت مشاركة المحادثة" - conversation_deleted: "تم حذف مشاركة المحادثة بنجاح" - spam_detection: - flag_reason: "تم الإبلاغ عنه كسلوكٍ عشوائي بواسطة Discourse AI" - silence_reason: "تم إسكات المستخدم تلقائيًا بواسطة Discourse AI" - invalid_error_type: "تم إدخال نوع خطأ غير صالح" - unexpected: "حدث خطأ غير متوقع" - bot_user_update_failed: "فشل تحديث مستخدم روبوت فحص السلوك العشوائي" - ai_bot: - reply_error: "عذراً، يبدو أن نظامنا واجه مشكلة غير متوقعة أثناء محاولة الرد.\n\n[details='Error details']\n%{details}\n[/details]" - default_pm_prefix: "[رسالة خاصة دون عنوان من روبوت ذكاء اصطناعي]" - personas: - default_llm_required: "نموذج اللغة الكبير الافتراضي مطلوب قبل تفعيل الدردشة" - cannot_delete_system_persona: "لا يمكن حذف شخصيات النظام، يُرجى إيقافها بدلًا من ذلك" - cannot_edit_system_persona: "يمكن إعادة تسمية شخصيات النظام فقط، ولا يجوز لك تعديل الأدوات أو رسائل مطالبة النظام. قم بإيقافها وعمل نسخة منها بدلًا من ذلك." - github_helper: - name: "مساعد GitHub" - description: "روبوت ذكاء اصطناعي متخصص في المساعدة في المهام والأسئلة المتعلقة بمنصة GitHub" - general: - name: مساعد المنتدى - description: "روبوت ذكاء اصطناعي للأغراض العامة قادر على أداء مهام مختلفة" - artist: - name: فنان - description: "روبوت ذكاء اصطناعي متخصص في إنشاء الصور" - sql_helper: - name: مساعد SQL - description: "روبوت ذكاء اصطناعي متخصص في المساعدة في صياغة استعلامات SQL في مثيل Discourse هذا" - settings_explorer: - name: مستكشف الإعدادات - description: "روبوت ذكاء اصطناعي متخصص في المساعدة في استكشاف إعدادات موقع Discourse" - creative: - name: المبدع - description: "روبوت ذكاء اصطناعي من دون تكاملات خارجية متخصصة في المهام الإبداعية" - dall_e3: - name: "DALL-E 3" - description: "روبوت ذكاء اصطناعي متخصص في إنشاء الصور باستخدام DALL-E 3" - discourse_helper: - name: "مساعد Discourse" - description: "روبوت ذكاء اصطناعي متخصص في المساعدة في المهام المتعلقة بمنصة Discourse" - web_artifact_creator: - name: "مُنشئ الملفات الثانوية على الويب" - description: "روبوت الذكاء الاصطناعي المتخصص في إنشاء ملفات ثانوية تفاعلية على الويب" - custom_prompt: - name: "رسالة مطالبة مخصَّصة" - smart_dates: - name: "التواريخ الذكية" - topic_not_found: "الملخص غير متوفر، الموضوع غير موجود!" - summarizing: "جارٍ تلخيص الموضوع" - searching: "جارٍ البحث عن: '%{query}'" - tool_options: - researcher: - max_results: - name: "الحد الأقصى لعدد النتائج" - google: - base_query: - name: "استعلام البحث الأساسي" - description: "الاستعلام الأساسي الذي سيتم استخدامه عند البحث. الأمثلة: سيتضمَّن \"site:example.com\" النتائج من example.com فقط، بينما سيتضمَّن before:2022-01-01 النتائج من عام 2021 وما قبله فقط. تتم إضافة هذا النص إلى استعلام البحث." - read: - read_private: - name: "قراءة خاصة" - description: "السماح بالوصول إلى جميع الموضوعات التي يمكن للمستخدم الوصول إليها (يتم تضمين الموضوعات العامة فقط بشكلٍ افتراضي)" - search: - search_private: - name: "بحث خاص" - description: "تضمين جميع الموضوعات التي يمكن للمستخدم الوصول إليها في نتائج البحث (يتم تضمين الموضوعات العامة فقط بشكلٍ افتراضي)" - max_results: - name: "الحد الأقصى لعدد النتائج" - description: "الحد الأقصى لعدد النتائج المطلوب تضمينها في البحث - في حال ترك الحقل فارغًا، سيتم استخدام القواعد الافتراضية وستتم زيادة العدد وفقًا للنموذج المُستخدَم. أعلى قيمة هي 100." - base_query: - name: "استعلام البحث الأساسي" - description: "استعلام البحث الأساسي الذي سيتم استخدامه عند البحث. على سبيل المثال: سيسبق \"#urgent\" \"#urgent\" في استعلام البحث وسيتضمن الموضوعات ذات الفئة أو الوسم العاجل فقط." - tool_summary: - update_artifact: "تحديث ملف ثانوي على الويب" - create_artifact: "إنشاء ملف ثانوي على الويب" - web_browser: "تصفح الويب" - github_search_files: "ملفات بحث GitHub" - github_search_code: "البحث عن رمز برمجي على GitHub" - github_file_content: "محتوى ملف GitHub" - github_pull_request_diff: "الفروقات في طلب السحب على GitHub" - random_picker: "منتقي عشوائي" - categories: "إدراج الفئات" - search: "البحث" - tags: "إدراج الوسوم" - time: "الوقت" - summarize: "تلخيص" - image: "إنشاء صورة" - google: "البحث في Google" - read: "قراءة الموضوع" - setting_context: "البحث عن سياق إعداد الموقع" - schema: "البحث عن مخطط قاعدة البيانات" - search_settings: "جارٍ البحث في إعدادات الموقع" - dall_e: "إنشاء صورة" - search_meta_discourse: "البحث في Meta Discourse" - javascript_evaluator: "تقييم JavaScript" - tool_help: - update_artifact: "تحديث ملف ثانوي على الويب باستخدام روبوت الذكاء الاصطناعي" - create_artifact: "إنشاء ملف ثانوي على الويب باستخدام روبوت الذكاء الاصطناعي" - web_browser: "تصفح صفحة الويب باستخدام روبوت ذكاء اصطناعي" - github_search_code: "البحث عن رمز برمجي في مستودع GitHub" - github_search_files: "البحث عن ملفات في مستودع GitHub" - github_file_content: "استعادة محتوى ملفات من مستودع GitHub" - github_pull_request_diff: "استعادة الفروقات في طلب السحب على GitHub" - random_picker: "اختر رقمًا عشوائيًا أو عنصرًا عشوائيًا من القائمة" - categories: "إدراج جميع الفئات المرئية بشكلٍ عام في المنتدى" - search: "البحث في جميع الموضوعات العامة في المنتدى" - tags: "إدراج جميع الوسوم في المنتدى" - time: "البحث عن الوقت في مناطق زمنية مختلفة" - summary: "تلخيص موضوع" - image: "إنشاء صورة باستخدام Stable Diffusion" - google: "البحث في Google عن استعلام" - read: "قراءة الموضوع العام في المنتدى" - setting_context: "البحث عن سياق إعداد الموقع" - schema: "البحث عن مخطط قاعدة البيانات" - search_settings: "البحث في إعدادات الموقع" - dall_e: "إنشاء صورة باستخدام DALL-E 3" - search_meta_discourse: "البحث في Meta Discourse" - javascript_evaluator: "تقييم JavaScript" - tool_description: - update_artifact: "تم تحديث ملف ثانوي على الويب باستخدام روبوت الذكاء الاصطناعي" - web_browser: "قراءة %{url}" - github_search_files: "تم البحث عن '%{keywords}' في %{repo}/%{branch}" - github_search_code: "تم البحث عن '%{query}' في %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "تم استعادة محتوى %{file_paths} من %{repo_name}@%{branch}" - random_picker: "الاختيار من %{options}، تم اختيار: %{result}" - read: "القراءة: %{title}" - time: "الوقت في المنطقة الزمنية %{timezone} هو %{time}" - summarize: "تم تلخيص %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - zero: "تم العثور على %{count} فئات" - one: "تم العثور على فئة واحدة (%{count})" - two: "تم العثور على فئتين (%{count})" - few: "تم العثور على %{count} فئات" - many: "تم العثور على %{count} فئات" - other: "تم العثور على %{count} فئات" - tags: - zero: "تم العثور على %{count} وسمًا" - one: "تم العثور على وسم واحد (%{count})" - two: "تم العثور على وسمين (%{count})" - few: "تم العثور على %{count} وسوم" - many: "تم العثور على %{count} وسمًا" - other: "تم العثور على %{count} وسمًا" - search: - zero: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - one: "تم العثور على %{count} نتيجة لاستعلام البحث '%{query}'" - two: "تم العثور على نتيجتين (%{count}) لاستعلام البحث '%{query}'" - few: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - many: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - other: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - search_meta_discourse: - zero: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - one: "تم العثور على %{count} نتيجة لاستعلام البحث '%{query}'" - two: "تم العثور على نتيجتين (%{count}) لاستعلام البحث '%{query}'" - few: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - many: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - other: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - google: - zero: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - one: "تم العثور على %{count} نتيجة لاستعلام البحث '%{query}'" - two: "تم العثور على نتيجتين (%{count}) لاستعلام البحث '%{query}'" - few: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - many: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - other: "تم العثور على %{count} نتائج لاستعلام البحث '%{query}'" - setting_context: "سياق القراءة لـ: %{setting_name}" - schema: "%{tables}" - search_settings: - zero: "تم العثور على %{count} نتائج لـ '%{query}'" - one: "تم العثور على نتيجة واحدة (%{count}) لـ '%{query}'" - two: "تم العثور على نتيجتين (%{count}) لـ '%{query}'" - few: "تم العثور على %{count} نتائج لـ '%{query}'" - many: "تم العثور على %{count} نتائج لـ '%{query}'" - other: "تم العثور على %{count} نتائج لـ '%{query}'" - summarization: - configuration_hint: - zero: "قم بتكوين هذه الإعدادات أولًا: %{settings}" - one: "قم بتكوين الإعداد `%{setting}` أولًا." - two: "قم بتكوين هذين الإعدادين أولًا: %{settings}" - few: "قم بتكوين هذه الإعدادات أولًا: %{settings}" - many: "قم بتكوين هذه الإعدادات أولًا: %{settings}" - other: "قم بتكوين هذه الإعدادات أولًا: %{settings}" - chat: - no_targets: "لم تكن هناك رسائل خلال الفترة المحدَّدة." - sentiment: - reports: - overall_sentiment: "المشاعر العامة (إيجابية - سلبية)" - post_emotion: - sadness: "الحزن \U0001F622" - surprise: "المفاجأة \U0001F631" - neutral: "محايدة \U0001F610" - fear: "الخوف \U0001F628" - anger: "الغضب \U0001F621" - joy: "المرح \U0001F600" - disgust: "الاشمئزاز \U0001F922" - sentiment_analysis: - positive: "إيجابية" - negative: "سلبية" - neutral: "محايدة" - llm: - configuration: - disable_module_first: "يجب عليك إيقاف %{setting} أولًا." - set_llm_first: "اضبط %{setting} أولًا" - model_unreachable: "لم نتمكن من الحصول على رد من هذا النموذج. تحقَّق من إعداداتك أولًا." - invalid_seeded_model: "لا يمكنك استخدام هذا النموذج مع هذه الميزة" - must_select_model: "يجب عليك تحديد نموذج لغة كبير أولًا" - endpoints: - not_configured: "%{display_name} (not configured)" - configuration_hint: - zero: "تأكَّد من تكوين الإعداد `%{settings}`." - one: "تأكَّد من تكوين الإعداد `%{settings}`." - two: "تأكَّد من تكوين الإعدادَين `%{settings}`." - few: "تأكَّد من تكوين الإعدادات `%{settings}`." - many: "تأكَّد من تكوين الإعدادات `%{settings}`." - other: "تأكَّد من تكوين الإعدادات `%{settings}`." - delete_failed: - zero: "لم نتمكن من حذف هذا النموذج لأن %{settings} يستخدمه. قم بتحديث الإعداد وحاول مرة أخرى." - one: "لم نتمكن من حذف هذا النموذج لأن %{settings} يستخدمه. قم بتحديث الإعداد وحاول مرة أخرى." - two: "لم نتمكن من حذف هذا النموذج لأن %{settings} يستخدمانه. قم بتحديث الإعدادَين وحاول مرة أخرى." - few: "لم نتمكن من حذف هذا النموذج لأن %{settings} تستخدمه. قم بتحديث الإعدادات وحاول مرة أخرى." - many: "لم نتمكن من حذف هذا النموذج لأن %{settings} تستخدمه. قم بتحديث الإعدادات وحاول مرة أخرى." - other: "لم نتمكن من حذف هذا النموذج لأن %{settings} تستخدمه. قم بتحديث الإعدادات وحاول مرة أخرى." - cannot_edit_builtin: "لا يمكنك تعديل نموذج مُدمَج." - embeddings: - delete_failed: "هذا النموذج قيد الاستخدام حاليًا. قم بتحديث `ai embeddings selected model` أولًا." - cannot_edit_builtin: "لا يمكنك تعديل نموذج مُدمَج." - configuration: - disable_embeddings: "يجب عليك إيقاف 'ai embeddings enabled' أولًا." - choose_model: "قم بتعيين 'ai embeddings selected model' أولًا." - llm_models: - missing_provider_param: "لا يمكن ترك %{param} فارغة" - bedrock_invalid_url: "يُرجى ملء جميع الحقول لاستخدام هذا النموذج." - ai_staff_action_logger: - updated: "تاريخ التحديث" - removed: "تمت إزالتها" - errors: - quota_exceeded: "لقد تجاوزت الحصة المُخصَّصة لهذا النموذج. يُرجى إعادة المحاولة بعد %{relative_time}." - quota_required: "يجب عليك تحديد الحد الأقصى للرموز أو الاستخدامات لهذا النموذج" - no_query_specified: معلمة الاستعلام مطلوبة، يُرجى تحديدها. - no_user_for_persona: لا تملك الشخصية المحدَّدة مستخدمًا مرتبطًا بها. - persona_not_found: الشخصية المحدَّدة غير موجودة. تحقَّق من معلمتَي persona_name أو persona_id. - no_user_specified: اسم المستخدم أو معلمة user_unique_id مطلوبة، يُرجى تحديدها. - user_not_found: المستخدم المحدَّد غير موجود. تحقَّق من معلمة username. - persona_disabled: الشخصية المحدَّدة متوقفة. تحقَّق من معلمتَي persona_name أو persona_id. - no_default_llm: يجب أن يكون للشخصية معلمة default_llm محدَّدة. - user_not_allowed: غير مسموح للمستخدم بالمشاركة في الموضوع. - prompt_message_length: تتجاوز الرسالة الحد الأقصى لعدد الحروف، والبالغ 1000 حرف، بمقدار %{idx}. diff --git a/config/locales/server.be.yml b/config/locales/server.be.yml deleted file mode 100644 index 37264e26..00000000 --- a/config/locales/server.be.yml +++ /dev/null @@ -1,20 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -be: - reports: - emotion_neutral: - title: "\U0001F610 нейтральны" - discourse_ai: - ai_bot: - tool_summary: - search: "Пошук" - sentiment: - reports: - post_emotion: - neutral: "нейтральны \U0001F610" - sentiment_analysis: - neutral: "нейтральны" diff --git a/config/locales/server.bg.yml b/config/locales/server.bg.yml deleted file mode 100644 index d8266523..00000000 --- a/config/locales/server.bg.yml +++ /dev/null @@ -1,18 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -bg: - reports: - overall_sentiment: - yaxis: "Дата" - discourse_ai: - ai_bot: - tool_summary: - search: "Търсене" - time: "Време " - summarize: "Обобщаване" - ai_staff_action_logger: - removed: "премахнато" diff --git a/config/locales/server.bs_BA.yml b/config/locales/server.bs_BA.yml deleted file mode 100644 index 1e36c099..00000000 --- a/config/locales/server.bs_BA.yml +++ /dev/null @@ -1,17 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -bs_BA: - reports: - overall_sentiment: - yaxis: "Datum" - discourse_ai: - ai_bot: - tool_summary: - search: "Pretraži" - time: "Vrijeme" - ai_staff_action_logger: - updated: "ažurirano" diff --git a/config/locales/server.ca.yml b/config/locales/server.ca.yml deleted file mode 100644 index 9f29be30..00000000 --- a/config/locales/server.ca.yml +++ /dev/null @@ -1,25 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ca: - reports: - overall_sentiment: - yaxis: "Data" - emotion_neutral: - title: "\U0001F610 Neutre" - discourse_ai: - ai_bot: - tool_summary: - search: "Cerca" - time: "Hora" - sentiment: - reports: - post_emotion: - neutral: "Neutre \U0001F610" - sentiment_analysis: - neutral: "Neutre" - ai_staff_action_logger: - updated: "actualitzat" diff --git a/config/locales/server.cs.yml b/config/locales/server.cs.yml deleted file mode 100644 index 94f870c1..00000000 --- a/config/locales/server.cs.yml +++ /dev/null @@ -1,38 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -cs: - reports: - overall_sentiment: - yaxis: "Datum" - discourse_ai: - ai_helper: - prompts: - translate: Přeložit do %{language} - generate_titles: Navrhnout názvy témat - proofread: Korektura textu - markdown_table: Generovat Markdown tabulku - custom_prompt: "Vlastní pokyn" - explain: "Vysvětlit" - illustrate_post: "Ilustrovat příspěvek" - replace_dates: "Chytré datumy" - ai_bot: - personas: - custom_prompt: - name: "Vlastní pokyn" - smart_dates: - name: "Chytré datumy" - topic_not_found: "Souhrn není k dispozici, téma nebylo nalezeno!" - summarizing: "Vytvářím souhrn tématu" - tool_summary: - search: "Vyhledat" - tags: "Seznam značek" - time: "Čas" - tool_help: - summary: "Shrnout téma" - ai_staff_action_logger: - updated: "aktualizováno" - removed: "odstraněno" diff --git a/config/locales/server.da.yml b/config/locales/server.da.yml deleted file mode 100644 index 32d16ea1..00000000 --- a/config/locales/server.da.yml +++ /dev/null @@ -1,26 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -da: - reports: - overall_sentiment: - yaxis: "Dato" - emotion_neutral: - title: "\U0001F610 Neutral" - discourse_ai: - ai_bot: - tool_summary: - search: "Søg" - time: "Tidspunkt" - sentiment: - reports: - post_emotion: - neutral: "Neutral \U0001F610" - sentiment_analysis: - neutral: "Neutral" - ai_staff_action_logger: - updated: "opdateret" - removed: "fjernet" diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml deleted file mode 100644 index 35b10698..00000000 --- a/config/locales/server.de.yml +++ /dev/null @@ -1,614 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -de: - discourse_automation: - ai: - flag_types: - review: "Beitrag zur Überprüfungswarteschlange hinzufügen" - review_hide: "Beitrag zur Warteschlange zur Überprüfung hinzufügen und Beitrag verstecken" - spam: "Als Spam markieren und Beitrag verbergen" - spam_silence: "Als Spam melden, Beitrag ausblenden und Benutzer stummschalten" - scriptables: - llm_tool_triage: - title: Sichtung von Beiträgen mit dem KI-Tool - description: "Sichtung von Beiträgen mit benutzerdefinierter Logik in einem KI-Tool" - llm_persona_triage: - title: Sichtung von Beiträgen mit KI-Persona - description: "Reagiere auf Beiträge mit einer bestimmten KI-Persona" - llm_triage: - title: Beiträge mithilfe von KI sortieren - description: "Beiträge mithilfe eines großen Sprachmodells sortieren" - flagged_post: | -
Antwort des Modells:
-

%%LLM_RESPONSE%%

- Ausgelöst durch die Regel %%AUTOMATION_NAME%%. - llm_report: - title: Regelmäßiger Bericht mit KI - description: "Regelmäßiger Bericht auf der Grundlage eines großen Sprachmodells" - site_settings: - discourse_ai_enabled: "Aktiviere das Discourse-KI-Plug-in." - ai_artifact_security: "Das KI-Artefaktsystem generiert IFRAMEs mit ausführbarem Code. Der strikte Modus erfordert einen zusätzlichen Klick zum Ausführen des Codes. Der laxe Modus führt Code sofort aus. Im Hybridmodus kann der Benutzer data-ai-artifact-autorun angeben, um den Code sofort anzuzeigen. Der deaktivierte Modus deaktiviert das Artefaktsystem." - ai_toxicity_enabled: "Aktiviere das Toxizitätsmodul." - ai_toxicity_inference_service_api_endpoint: "URL, unter der die API für das Toxizitätsmodul läuft" - ai_toxicity_inference_service_api_key: "API-Schlüssel für die Toxizitäts-API" - ai_toxicity_inference_service_api_model: "Modell, das für die Inferenz verwendet wird. Das mehrsprachige Modell funktioniert mit Italienisch, Französisch, Russisch, Portugiesisch, Spanisch und Türkisch." - ai_toxicity_flag_automatically: "Markiere automatisch Beiträge/Chat-Nachrichten, die über den konfigurierten Schwellenwerten liegen." - ai_toxicity_flag_threshold_toxicity: "Toxizität: ein unhöflicher, respektloser oder unangemessener Kommentar, der dich mit einiger Wahrscheinlichkeit dazu bringt, eine Diskussion zu verlassen oder deinen Standpunkt nicht mehr zu teilen" - ai_toxicity_flag_threshold_severe_toxicity: "Drastische Toxizität: ein sehr hasserfüllter, aggressiver oder respektloser Kommentar, der dich mit hoher Wahrscheinlichkeit dazu bringt, eine Diskussion zu verlassen oder deinen Standpunkt nicht mehr zu teilen" - ai_toxicity_flag_threshold_obscene: "Obszön" - ai_toxicity_flag_threshold_identity_attack: "Identitätsangriff" - ai_toxicity_flag_threshold_insult: "Beleidigung" - ai_toxicity_flag_threshold_threat: "Bedrohung" - ai_toxicity_flag_threshold_sexual_explicit: "Sexuell explizit" - ai_toxicity_groups_bypass: "Die Beiträge von Benutzern in diesen Gruppen werden nicht durch das Toxizitätsmodul eingestuft." - ai_sentiment_enabled: "Aktiviere das Stimmungsmodul." - ai_sentiment_inference_service_api_endpoint: "URL, unter der die API für das Stimmungsmodul läuft" - ai_sentiment_inference_service_api_key: "API-Schlüssel für die Stimmungsmodul-API" - ai_sentiment_models: "Modelle, die für die Inferenz verwendet werden. Das Stimmungsmodul klassifiziert Beiträge in den Bereichen positiv/neutral/negativ. Emotionen werden in die Bereiche Wut/Ekel/Furcht/Freude/neutral/Traurigkeit/Überraschung eingeordnet." - ai_nsfw_detection_enabled: "Aktiviere das NSFW-Modul." - ai_nsfw_inference_service_api_endpoint: "URL, unter der die API für das NSFW-Modul läuft" - ai_nsfw_inference_service_api_key: "API-Schlüssel für die NSFW-API" - ai_nsfw_flag_automatically: "Kennzeichne automatisch NSFW-Beiträge, die über den konfigurierten Schwellenwerten liegen." - ai_nsfw_flag_threshold_general: "Allgemeiner Schwellenwert, ab dem ein Bild als NSFW gilt." - ai_nsfw_flag_threshold_drawings: "Schwellenwert, ab dem eine Zeichnung als NSFW gilt." - ai_nsfw_flag_threshold_hentai: "Schwellenwert, ab dem ein Bild, das als Hentai eingestuft wird, als NSFW gilt." - ai_nsfw_flag_threshold_porn: "Schwellenwert, ab dem ein Bild, das als Porno eingestuft wird, als NSFW gilt." - ai_nsfw_flag_threshold_sexy: "Schwellenwert, ab dem ein Bild, das als sexy eingestuft wird, als NSFW gilt." - ai_nsfw_models: "Modelle, die für NSFW-Inferenz verwendet werden." - ai_spam_detection_enabled: "Aktiviere das KI-Spamerkennungsmodul" - ai_openai_api_key: "API-Schlüssel für OpenAI-API. Wird NUR für Bilderstellung und -bearbeitung verwendet. Für GPT verwende die Registerkarte für die LLM-Konfiguration" - ai_openai_image_generation_url: "URL für die OpenAI-Bilderstellungs-API" - ai_openai_image_edit_url: "URL für die OpenAI-Bildbearbeitungs-API" - ai_helper_enabled: "Aktiviere den KI-Helfer." - composer_ai_helper_allowed_groups: "Benutzer dieser Gruppen sehen die KI-Helfer-Schaltfläche im Composer." - ai_helper_allowed_in_pm: "Aktiviere den Composer-KI-Helfer in PN." - ai_helper_model: "Modell, das für den KI-Helfer verwendet werden soll." - ai_helper_custom_prompts_allowed_groups: "Die Benutzer dieser Gruppen sehen die Option „Benutzerdefinierte Eingabeaufforderung“ im KI-Helfer." - ai_helper_automatic_chat_thread_title_delay: "Verzögerung in Minuten, bevor der KI-Helfer automatisch den Titel des Chat-Threads festlegt." - ai_helper_automatic_chat_thread_title: "Lege die Titel der Chat-Threads automatisch anhand der Thread-Inhalte fest." - ai_helper_illustrate_post_model: "Modell, das für die Funktion „Beitrag illustrieren“ des Composer-KI-Helfers verwendet wird" - ai_helper_enabled_features: "Wähle die Funktionen aus, die im KI-Helfer aktiviert werden sollen." - post_ai_helper_allowed_groups: "Nutzergruppen, die auf KI-Helfer-Funktionen in Beiträgen zugreifen dürfen" - ai_helper_image_caption_model: "Wähle das Modell aus, das für die Erstellung von Bildbeschriftungen verwendet werden soll" - ai_auto_image_caption_allowed_groups: "Benutzer dieser Gruppen können die automatische Bildbeschriftung ein- und ausschalten." - ai_embeddings_selected_model: "Verwende das ausgewählte Modell für die Erzeugung von Einbettungen." - ai_embeddings_generate_for_pms: "Erstelle Einbettungen für persönliche Nachrichten." - ai_embeddings_semantic_related_topics_enabled: "Verwende die semantische Suche für verwandte Themen." - ai_embeddings_semantic_related_topics: "Maximale Anzahl der Themen, die im Abschnitt für verwandte Themen angezeigt werden sollen." - ai_embeddings_backfill_batch_size: "Anzahl der Einbettungen, die alle 15 Minuten aufgefüllt werden." - ai_embeddings_semantic_search_enabled: "Aktiviere die semantische Ganzseitensuche." - ai_embeddings_semantic_quick_search_enabled: "Aktiviere die semantische Suchoption im Such-Menü-Pop-up." - ai_embeddings_semantic_related_include_closed_topics: "Geschlossene Themen in semantische Suchergebnisse einbeziehen" - ai_embeddings_semantic_search_hyde_model: "Modell zur Erweiterung von Schlüsselwörtern, um bessere Ergebnisse bei der semantischen Suche zu erzielen" - ai_embeddings_per_post_enabled: Erstelle Einbettungen für jeden Beitrag - ai_summarization_enabled: "Aktiviere die Zusammenfassungsfunktion" - ai_summarization_model: "Modell, das für die Zusammenfassung verwendet werden soll" - ai_summarization_persona: "Persona, die für die Zusammenfassungsfunktion verwendet werden soll" - ai_custom_summarization_allowed_groups: "Gruppen, die neue Zusammenfassungen erstellen dürfen." - ai_pm_summarization_allowed_groups: "Gruppen, die Zusammenfassungen in PN erstellen und ansehen dürfen." - ai_summary_gists_enabled: "Erstelle automatisch kurze Zusammenfassungen der letzten Antworten in Themen" - ai_summary_gists_allowed_groups: "Gruppen, die zentrale Punkte in der Liste der aktuellen Themen sehen dürfen." - ai_summary_backfill_maximum_topics_per_hour: "Anzahl der Themenzusammenfassungen, die pro Stunde nachgeholt werden." - ai_bot_enabled: "Aktiviere das KI-Bot-Modul." - ai_bot_enable_chat_warning: "Zeigt eine Warnung an, wenn der PN-Chat initiiert wird. Kann durch Bearbeiten der Übersetzungszeichenfolge überschrieben werden: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Wenn der GPT-Bot Zugriff auf die PN hat, wird er den Mitgliedern dieser Gruppen antworten." - ai_bot_debugging_allowed_groups: "Erlaube diesen Gruppen, eine Debug-Schaltfläche in Beiträgen zu sehen, der die rohe KI-Anfrage und -Antwort anzeigt." - ai_bot_public_sharing_allowed_groups: "Erlaube diesen Gruppen, persönliche KI-Nachrichten über einen eindeutigen, öffentlich zugänglichen Link mit der Öffentlichkeit zu teilen. Hinweis: Wenn für deine Website eine Anmeldung erforderlich ist, ist auch zum Ansehen des geteilten Inhalts eine Anmeldung erforderlich." - ai_bot_add_to_header: "Eine Schaltfläche in der Kopfzeile anzeigen, um eine PN mit einem KI-Bot zu starten" - ai_bot_github_access_token: "GitHub-Zugangstoken für die Verwendung mit den GitHub-KI-Tools (erforderlich für die Suchunterstützung)" - ai_stability_api_key: "API-Schlüssel für die stability.ai-API" - ai_stability_engine: "Bildgenerierungsengine für die stability.ai-API" - ai_stability_api_url: "URL für die stability.ai-API" - ai_google_custom_search_api_key: "API-Schlüssel für die Google Custom Search API, siehe: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX für Google Custom Search API" - ai_discord_search_enabled: "Aktiviert die Discord-Suchfunktion" - ai_discord_app_id: "Die ID der Discord-Anwendung, mit der du die Discord-Suche verbinden möchtest" - ai_discord_app_public_key: "Der öffentliche Schlüssel der Discord-Anwendung, mit der du die Discord-Suche verbinden möchtest" - ai_discord_search_mode: "Wähle den Suchmodus für die Discord-Suche aus" - ai_discord_search_persona: "Die Persona, die für die Discord-Suche verwendet wird." - ai_discord_allowed_guilds: "Discord-Gilden (Server), auf denen der Bot suchen darf" - ai_bot_enable_dedicated_ux: "Erlaube eine Vollbild-Bot-Oberfläche anstelle einer PM" - ai_translation_enabled: "Aktiviert die KI-Übersetzungsfunktion" - ai_translation_model: "Das Modell, das für die Übersetzung verwendet werden soll. Dieses Modell muss die Übersetzung unterstützen. Personas können diese Einstellung überschreiben." - ai_translation_backfill_limit_to_public_content: "Wenn diese Option aktiviert ist, werden nur Inhalte in öffentlichen Kategorien übersetzt. Wenn sie deaktiviert ist, werden auch Inhalte in Gruppen-PMs und privaten Kategorien zur Übersetzung gesendet." - ai_translation_max_post_length: "Die maximale Länge eines zu übersetzenden Beitrags. Längere Beiträge werden nicht übersetzt." - ai_translation_backfill_max_age_days: "Das maximale Alter eines Beitrags oder Themas, das übersetzt werden soll. Beiträge und Themen, die älter sind, werden nicht übersetzt. 0 deaktiviert die rückwirkende Übersetzung, aber nicht die Übersetzung von neuen Beiträgen." - reviewables: - reasons: - flagged_by_toxicity: Das KI-Plug-in meldete dies nach der Klassifizierung als toxisch. - flagged_by_nsfw: Das KI-Plug-in meldete dies nach der Klassifizierung von mindestens einem der angehängten Bilder als NSFW. - reports: - sentiment_analysis: - title: "Stimmungsanalyse" - description: "Dieser Bericht enthält eine Stimmungsanalyse für Beiträge, gruppiert nach Kategorie, mit positiven, negativen und neutralen Bewertungen für jeden Beitrag und jede Kategorie." - overall_sentiment: - title: "Allgemeine Stimmung" - description: 'Das Diagramm vergleicht die Anzahl der Beiträge, die entweder als positiv oder negativ eingestuft werden. Sie werden berechnet, wenn die positive oder negative Bewertung über dem festgelegten Schwellenwert liegt. Das bedeutet, dass neutrale Beiträge nicht angezeigt werden. Persönliche Nachrichten (PN) sind ebenfalls ausgeschlossen. Klassifiziert mit „cardiffnlp/twitter-roberta-base-sentiment-latest“' - xaxis: "Positiv (%)" - yaxis: "Datum" - emotion_admiration: - title: "\U0001F929 Bewunderung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Bewunderung“ klassifiziert wurden." - emotion_amusement: - title: "\U0001F604 Unterhaltung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Unterhaltung“ klassifiziert wurden." - emotion_anger: - title: "\U0001F620 Wut" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Wut“ klassifiziert wurden." - emotion_annoyance: - title: "\U0001F612 Ärgernis" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Ärgernis“ klassifiziert wurden." - emotion_approval: - title: "\U0001F44D Zustimmung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Zustimmung“ klassifiziert wurden." - emotion_caring: - title: "\U0001F917 Fürsorge" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Fürsorge“ klassifiziert wurden." - emotion_confusion: - title: "\U0001F615 Verwirrung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Verwirrung“ klassifiziert wurden." - emotion_curiosity: - title: "\U0001F914 Neugierde" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Neugierde“ klassifiziert wurden." - emotion_desire: - title: "\U0001F60D Wunsch" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Wunsch“ klassifiziert wurden." - emotion_disappointment: - title: "\U0001F61E Enttäuschung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Enttäuschung“ klassifiziert wurden." - emotion_disapproval: - title: "\U0001F44E Missbilligung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Missbilligung“ klassifiziert wurden." - emotion_disgust: - title: "\U0001F922 Ekel" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Ekel“ klassifiziert wurden." - emotion_embarrassment: - title: "\U0001F633 Verlegenheit" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Verlegenheit“ klassifiziert wurden." - emotion_excitement: - title: "\U0001F92A Aufregung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Aufregung“ klassifiziert wurden." - emotion_fear: - title: "\U0001F628 Furcht" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Furcht“ klassifiziert wurden." - emotion_gratitude: - title: "\U0001F64F Dankbarkeit" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Dankbarkeit“ klassifiziert wurden." - emotion_grief: - title: "\U0001F622 Kummer" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Kummer“ klassifiziert wurden." - emotion_joy: - title: "\U0001F60A Freude" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Freude“ klassifiziert wurden." - emotion_love: - title: '❤️ Liebe' - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Liebe“ klassifiziert wurden." - emotion_nervousness: - title: "\U0001F630 Nervosität" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Nervosität“ klassifiziert wurden." - emotion_neutral: - title: "\U0001F610 Neutral" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Neutral“ klassifiziert wurden." - emotion_optimism: - title: "\U0001F31F Optimismus" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Optimismus“ klassifiziert wurden." - emotion_pride: - title: "\U0001F981 Stolz" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Stolz“ klassifiziert wurden." - emotion_realization: - title: "\U0001F4A1 Realisierung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Realisierung“ klassifiziert wurden." - emotion_relief: - title: "\U0001F60C Erleichterung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Erleichterung“ klassifiziert wurden." - emotion_remorse: - title: "\U0001F614 Reue" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Reue“ klassifiziert wurden." - emotion_sadness: - title: "\U0001F62D Traurigkeit" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Traurigkeit“ klassifiziert wurden." - emotion_surprise: - title: "\U0001F632 Überraschung" - description: "Beiträge, die per KI unter Verwendung des Modells „SamLowe/roberta-base-go_emotions“ mit der Emotion „Überraschung“ klassifiziert wurden." - discourse_ai: - ai_artifact: - errors: - max_keys_exceeded: - one: "Du kannst nur %{count} Schlüssel im Artefakt haben." - other: "Du kannst nur %{count} Schlüssel im Artefakt haben." - link: "In neuem Tab anzeigen" - copy_embed: "Einbettung kopieren" - view_source: "Quelle anzeigen" - view_changes: "Änderungen anzeigen" - change_description: "Beschreibung ändern" - copied: "Wurde in Zwischenablage kopiert" - unknown_model: "Unbekanntes KI-Modell" - tools: - custom_name: "%{name} (benutzerdefiniert)" - presets: - browse_web_jina: - name: "Web durchsuchen (jina.ai)" - exchange_rate: - name: "Wechselkurs" - stock_quote: - name: "Aktienkurs (AlphaVantage)" - image_generation: - name: "Flux-Bildgenerator (Together.ai)" - empty_tool: - name: "Von vorne beginnen …" - name: - characters: "darf nur Zahlen, Buchstaben und Unterstriche enthalten" - ai_helper: - errors: - completion_request_failed: "Beim Versuch, Vorschläge zu machen, ist etwas schiefgelaufen. Bitte versuche es noch einmal." - prompts: - translate: Übersetzen in %{language} - generate_titles: Thementitel vorschlagen - proofread: Text korrekturlesen - markdown_table: Markdown-Tabelle generieren - custom_prompt: "Benutzerdefinierte Eingabeaufforderung" - explain: "Erklären" - illustrate_post: "Beitrag illustrieren" - replace_dates: "Intelligente Termine" - painter: - attribution: - stable_diffusion_xl: "Bild von Stable Diffusion XL" - dall_e_3: "Bild von DALL-E 3" - image_caption: - attribution: "Beschriftet durch KI" - share_ai: - read_more: "Vollständiges Transkript lesen" - onebox_title: "KI-Unterhaltung mit %{llm_name}" - formatted_excerpt: "KI-Unterhaltung mit %{llm_name}:\n %{excerpt}" - title: "%{title} – KI-Unterhaltung – %{site_name}" - errors: - not_allowed: "Du darfst dieses Thema nicht teilen" - other_people_in_pm: "Persönliche Nachrichten mit anderen Menschen können nicht öffentlich geteilt werden" - other_content_in_pm: "Persönliche Nachrichten, die Beiträge von anderen Personen enthalten, können nicht öffentlich geteilt werden" - failed_to_share: "Die Unterhaltung konnte nicht geteilt werden" - conversation_deleted: "Unterhaltungsfreigabe erfolgreich gelöscht" - spam_detection: - flag_reason: "Als Spam gemeldet von Discourse KI" - silence_reason: "Benutzer automatisch stummgeschaltet durch Discourse KI" - invalid_error_type: "Ungültiger Fehlertyp angegeben" - unexpected: "Ein unerwarteter Fehler ist aufgetreten" - bot_user_update_failed: "Aktualisierung des Spam-Scan-Bot-Benutzers fehlgeschlagen" - configuration_missing: "Die Konfiguration der KI-Spamerkennung fehlt. Füge die Konfiguration in „Administration > Plug-ins > Discourse KI > Spam“ hinzu, bevor du sie aktivierst." - logging_subject: "Spam-Erkennung" - ai_bot: - reply_error: "Entschuldigung, es sieht so aus, als ob unser System beim Versuch, zu antworten, auf ein unerwartetes Problem gestoßen ist.\n\n[details='Fehlerdetails']\n%{details}\n[/details]" - default_pm_prefix: "[KI-Bot-PN ohne Titel]" - thinking: "Ich denke..." - personas: - default_llm_required: "Standard-LLM-Modell ist erforderlich, bevor der Chat aktiviert werden kann" - cannot_delete_system_persona: "System-Personas können nicht gelöscht werden, bitte deaktiviere sie stattdessen" - cannot_edit_system_persona: "System-Personas können nur umbenannt werden. Du darfst keine Tools oder die System-Eingabeaufforderung bearbeiten, sondern musst sie deaktivieren und eine Kopie erstellen" - cannot_have_duplicate_tools: "Es kann keine doppelten Werkzeuge geben" - github_helper: - name: "GitHub-Helfer" - description: "KI-Bot, der auf die Unterstützung bei GitHub-bezogenen Aufgaben und Fragen spezialisiert ist" - general: - name: Forum-Helfer - description: "Universeller KI-Bot, der verschiedene Aufgaben erfüllen kann" - artist: - name: Künstler - description: "KI-Bot, spezialisiert auf die Erstellung von Bildern" - designer: - name: Designer - description: "KI-Bot, spezialisiert auf die Erstellung und Bearbeitung von Bildern" - forum_researcher: - name: Forum-Rechercheur - description: "KI-Bot, spezialisiert auf gründliche Recherche für das Forum" - sql_helper: - name: SQL-Helfer - description: "KI-Bot, der darauf spezialisiert ist, bei der Erstellung von SQL-Abfragen auf dieser Discourse-Instanz zu helfen" - settings_explorer: - name: Einstellungs-Explorer - description: "KI-Bot, der darauf spezialisiert ist, die Einstellungen der Discourse-Website zu erkunden" - researcher: - name: Web-Rechercheur - description: "KI-Bot mit Google-Zugriff, der Webseiten sowohl suchen als auch lesen kann" - creative: - name: Kreativ - description: "KI-Bot ohne externe Integrationen, spezialisiert auf kreative Aufgaben" - dall_e3: - name: "DALL-E 3" - description: "KI-Bot, spezialisiert auf die Erstellung von Bildern mit DALL-E 3" - discourse_helper: - name: "Discourse-Helfer" - description: "KI-Bot, der auf die Unterstützung bei Discourse-bezogenen Aufgaben spezialisiert ist" - web_artifact_creator: - name: "Web-Artefakt-Ersteller" - description: "KI-Bot, spezialisiert auf die Erstellung interaktiver Web-Artefakte" - summarizer: - name: "Zusammenfasser" - description: "Standard-Persona für die KI-Zusammenfassungen" - short_summarizer: - name: "Zusammenfasser (Kurzform)" - description: "Standard-Persona zur Erstellung von KI-Kurzzusammenfassungen für die Elemente der Themenlisten" - concept_finder: - name: "Konzept-Finder" - description: "KI-Bot spezialisiert auf die Identifizierung von Konzepten und Themen in Inhalten" - concept_matcher: - name: "Konzept-Verbinder" - description: "KI-Bot spezialisiert auf den Abgleich von Inhalten mit bestehenden Konzepten" - concept_deduplicator: - name: "Konzept-Deduplikator" - description: "KI-Bot spezialisiert auf das Entfernen von redundanten Daten in Konzepten" - custom_prompt: - name: "Benutzerdefinierte Eingabeaufforderung" - description: "Standard-Persona für die „Benutzerdefinierte Eingabeaufforderungs“-Funktion des Helfers" - smart_dates: - name: "Intelligente Termine" - description: "Standard-Persona für die „Intelligente Termine“-Funktion des Helfers" - markdown_table_generator: - name: "Markdown-Tabellengenerator" - description: "Standard-Persona für die „Generiere Markdown-Tabelle“-Funktion des Helfers" - post_illustrator: - name: "Beitragsillustrator" - description: "Generiert StableDiffusion-Eingabeaufforderungen, für die Funktion „Beitrag illustrieren“ des Helfers" - proofreader: - name: "Korrekturleser" - description: "Standard-Persona für die „Korrekturlesen“-Funktion des Helfers" - titles_generator: - name: "Titelgenerator" - description: "Standard-Persona für die „Thementitel vorschlagen“-Funktion des Helfers" - tutor: - name: "Tutor" - description: "Standard-Persona für die „Erklären“-Funktion des Helfers" - translator: - name: "Übersetzer" - description: "Standard-Persona für die Übersetzerfunktion des Helfers" - image_captioner: - name: "Bildbeschriftungen" - description: "Standard-Persona für die Bildbeschriftungsfunktion des Helfers" - locale_detector: - name: "Sprachdetektor" - description: "Unterstützt die Übersetzungsfunktion durch Erkennen der Sprache eines bestimmten Textes (Beiträge, Titel usw.)." - post_raw_translator: - name: "Beitragsübersetzer" - description: "Unterstützt die Übersetzungsfunktion durch die Übersetzung von Beiträgen, die Discourse Markdown enthalten" - topic_title_translator: - name: "Thementitel-Übersetzer" - description: "Unterstützt die Übersetzungsfunktion durch die Übersetzung von Thementiteln" - short_text_translator: - name: "Kurztext-Übersetzer" - description: "Betreibt die Übersetzungsfunktion als generischer Textübersetzer, der für kurze Texte wie Kategorienamen oder Schlagwörter verwendet wird." - spam_detector: - name: "Spam-Detektor" - description: "Standard-Persona, die unsere Spam-Erkennungsfunktion betreibt" - content_creator: - name: "Inhaltsersteller" - description: "Standard-Persona für die HyDE-Suche" - topic_not_found: "Zusammenfassung nicht verfügbar, Thema nicht gefunden!" - summarizing: "Thema zusammenfassen" - searching: "Suche nach: „%{query}“" - tool_options: - researcher: - researcher_llm: - name: "LLM" - description: "Für die Recherche zu verwendendes Sprachmodell (standardmäßig das LLM der aktuellen Persona)" - max_tokens_per_batch: - name: "Maximale Token pro Bündel" - description: "Maximale Anzahl von Token, die für jedes Bündel in der Recherche verwendet werden" - max_tokens_per_post: - name: "Maximale Token pro Beitrag" - description: "Maximale Anzahl von Token, die für jeden Beitrag in der Recherche verwendet werden können" - max_results: - name: "Maximale Anzahl von Ergebnissen" - description: "Maximale Anzahl von Ergebnissen, die in einen Filter aufgenommen werden" - include_private: - name: "Private einschließen" - description: "Private Themen in die Filter einbeziehen" - create_artifact: - creator_llm: - name: "LLM" - description: "Sprachmodell, das für die Erstellung von Artefakten verwendet werden soll" - update_artifact: - editor_llm: - name: "LLM" - description: "Sprachmodell, das für Artefaktbearbeitungen verwendet werden soll" - update_algorithm: - name: "Algorithmus aktualisieren" - description: "Bitten LLM, den gesamten Austausch vorzunehmen, oder verwenden diff zum Aktualisieren" - do_not_echo_artifact: - name: "Kein Echo Artefakt" - description: "Begrenzt die Kosten, reduziert aber die Effektivität von Artefakt-Updates" - google: - base_query: - name: "Basissuchanfrage" - description: "Basisanfrage, die bei der Suche verwendet werden soll. Beispiele: „site:example.com“ enthält nur Ergebnisse von example.com, „before:2022-01-01“ enthält nur Ergebnisse aus dem Jahr 2021 und früher. Dieser Text wird der Suchanfrage vorangestellt." - read: - read_private: - name: "Privat lesen" - description: "Erlaube den Zugriff auf alle Themen, auf die der Nutzer Zugriff hat (standardmäßig sind nur öffentliche Themen verfügbar)" - search: - search_private: - name: "Privat suchen" - description: "Alle Themen, auf die der Nutzer Zugriff hat, in die Suchergebnisse einbeziehen (standardmäßig werden nur öffentliche Themen einbezogen)" - max_results: - name: "Maximale Anzahl von Ergebnissen" - description: "Maximale Anzahl der Ergebnisse, die in die Suche einbezogen werden sollen – wenn leer, werden die Standardregeln verwendet und die Anzahl wird je nach verwendetem Modell skaliert. Der höchste Wert ist 100." - base_query: - name: "Basissuchanfrage" - description: "Basisanfrage, die bei der Suche verwendet wird. Beispiel: Bei „#dringend“ wird der Suchanfrage „#dringend“ vorangestellt und es werden nur Themen mit der Kategorie oder dem Schlagwort „dringend“ angezeigt." - tool_summary: - read_artifact: "Ein Web-Artefakt lesen" - update_artifact: "Aktualisieren eines Web-Artefakts" - create_artifact: "Web-Artefakt erstellen" - web_browser: "Web durchsuchen" - github_search_files: "GitHub-Datei-Suche" - github_search_code: "GitHub-Code-Suche" - github_file_content: "GitHub-Datei-Inhalt" - github_pull_request_diff: "GitHub-Pull-Request-Diff" - random_picker: "Zufallsauswahl" - categories: "Kategorien auflisten" - search: "Suche" - tags: "Schlagwörter auflisten" - time: "Zeit" - summarize: "Zusammenfassen" - image: "Bild generieren" - google: "Google-Suche" - read: "Thema lesen" - setting_context: "Kontext der Website-Einstellung nachschlagen" - schema: "Datenbankschema nachschlagen" - search_settings: "Website-Einstellungen werden durchsucht" - dall_e: "Bild generieren" - search_meta_discourse: "Meta-Discourse durchsuchen" - javascript_evaluator: "JavaScript auswerten" - create_image: "Bild erstellen" - edit_image: "Bild bearbeiten" - researcher: "Rechercheur" - researcher_dry_run: "Recherche vorbereiten" - tool_help: - read_artifact: "Lesen eines Webartefakts mit dem KI-Bot" - update_artifact: "Aktualisiere ein Web-Artefakt mit dem KI-Bot" - create_artifact: "Erstelle ein Web-Artefakt mit dem KI-Bot" - web_browser: "Webseite mit dem KI-Bot durchsuchen" - github_search_code: "Code in einem GitHub-Repository suchen" - github_search_files: "Dateien in einem GitHub-Repository suchen" - github_file_content: "Inhalt von Dateien aus einem GitHub-Repository abrufen" - github_pull_request_diff: "GitHub-Pull-Request-Diff abrufen" - random_picker: "Zufallszahl oder zufälliges Element aus einer Liste auswählen" - categories: "Alle öffentlich sichtbaren Kategorien im Forum auflisten" - search: "Alle öffentlichen Themen im Forum durchsuchen" - tags: "Alle Schlagwörter im Forum auflisten" - time: "Zeit in verschiedenen Zeitzonen finden" - summary: "Ein Thema zusammenfassen" - image: "Bild mit Stable Diffusion generieren" - create_image: "Erstelle ein Bild mit dem Open AI GPT Bildmodell" - edit_image: "Bearbeite ein Bild mit dem Open AI GPT Bildmodell" - google: "Bei Google nach einer Anfrage suchen" - read: "Öffentliches Thema im Forum lesen" - setting_context: "Kontext der Website-Einstellung nachschlagen" - schema: "Datenbankschema nachschlagen" - search_settings: "Website-Einstellungen durchsuchen" - dall_e: "Bild mit DALL-E 3 generieren" - search_meta_discourse: "Meta-Discourse durchsuchen" - javascript_evaluator: "JavaScript auswerten" - researcher: "Foreninformationen mit dem KI-Bot recherchieren" - tool_description: - read_artifact: "Lesen eines Webartefakts mit dem KI-Bot" - update_artifact: "Ein Web-Artefakt wurde mit dem KI-Bot aktualisiert" - create_artifact: "Erstellt ein Web-Artefakt: %{name} - %{specification}" - web_browser: "%{url} wird gelesen" - github_search_files: "Gesucht wurde nach „%{keywords}“ in %{repo}/%{branch}" - github_search_code: "Gesucht wurde nach „%{query}“ in %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Inhalt von %{file_paths} in %{repo_name}@%{branch} wurde abgerufen" - random_picker: "Auswahl aus %{options}. Ausgewählt wurde: %{result}" - read: "Wird gelesen: %{title}" - time: "Die Uhrzeit in %{timezone} ist %{time}" - summarize: "%{title} zusammengefasst" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "%{count} Kategorie gefunden" - other: "%{count} Kategorien gefunden" - tags: - one: "%{count} Schlagwort gefunden" - other: "%{count} Schlagwörter gefunden" - search: - one: "%{count} Ergebnis für „%{query}“ gefunden" - other: "%{count} Ergebnisse für „%{query}“ gefunden" - search_meta_discourse: - one: "%{count} Ergebnis für „%{query}“ gefunden" - other: "%{count} Ergebnisse für „%{query}“ gefunden" - google: - one: "%{count} Ergebnis für „%{query}“ gefunden" - other: "%{count} Ergebnisse für „%{query}“ gefunden" - setting_context: "Kontext wird gelesen für: %{setting_name}" - schema: "%{tables}" - researcher_dry_run: - one: "Vorgeschlagene Ziele: %{goals}\n\n%{count} Beitrag passend zu „%{filter}“ gefunden" - other: "Vorgeschlagene Ziele: %{goals}\n\n%{count} Beiträge passend zu „%{filter}“ gefunden" - researcher: - one: "Recherche: %{goals}\n\n%{count} Beitrag passend zu „%{filter}“ gefunden" - other: "Recherche: %{goals}\n\n%{count} Beiträge passend zu „%{filter}“ gefunden" - search_settings: - one: "%{count} Ergebnis für „%{query}“ gefunden" - other: "%{count} Ergebnisse für „%{query}“ gefunden" - discoveries: - continue_conversation: - title: "Entdeckungsunterhaltung: Suche nach %{query}" - raw: "Bei meiner Suche nach %{query} hast du mir die folgenden Informationen gezeigt:\n\n%{context}\n\nLass uns die Unterhaltung fortsetzen." - summarization: - configuration_hint: - one: "Konfiguriere zunächst die Einstellung `%{setting}`." - other: "Konfiguriere zunächst diese Einstellungen: %{settings}" - chat: - no_targets: "Im ausgewählten Zeitraum gab es keine Nachrichten." - sentiment: - reports: - overall_sentiment: "Gesamtstimmung (positiv–negativ)" - post_emotion: - sadness: "Traurigkeit \U0001F622" - surprise: "Überraschung \U0001F631" - neutral: "Neutral \U0001F610" - fear: "Furcht \U0001F628" - anger: "Wut \U0001F621" - joy: "Freude \U0001F600" - disgust: "Ekel \U0001F922" - sentiment_analysis: - positive: "Positiv" - negative: "Negativ" - neutral: "Neutral" - llm: - configuration: - create_llm: "Du musst einen LLM einrichten, bevor du diese Funktion aktivierst" - disable_module_first: "Du musst zuerst %{setting} deaktivieren." - set_llm_first: "Stelle zuerst %{setting} ein" - model_unreachable: "Wir konnten keine Antwort von diesem Modell abrufen. Überprüfe zuerst deine Einstellungen." - invalid_seeded_model: "Du kannst dieses Modell nicht mit dieser Funktion verwenden" - invalid_persona_response_format: "Die ausgewählte Persona muss ein Antwortformat mit einem booleschen Feld namens „spam“ haben." - must_select_model: "Du musst zuerst ein LLM auswählen" - endpoints: - not_configured: "%{display_name} (nicht konfiguriert)" - configuration_hint: - one: "Vergewissere dich, dass die Einstellung \"%{settings}\" konfiguriert wurde." - other: "Vergewissere dich, dass diese Einstellungen konfiguriert wurden: %{settings}" - delete_failed: - one: "Wir konnten dieses Modell nicht löschen, weil es von %{settings} verwendet wird. Aktualisiere die Einstellung und versuche es erneut." - other: "Wir konnten dieses Modell nicht löschen, weil %{settings} es verwenden. Aktualisiere die Einstellungen und versuche es erneut." - cannot_edit_builtin: "Du kannst ein integriertes Modell nicht bearbeiten." - personas: - malformed_examples: "Die angegebenen Beispiele haben das falsche Format." - embeddings: - delete_failed: "Dieses Modell wird derzeit verwendet. Aktualisiere zuerst `ai embeddings selected model`." - cannot_edit_builtin: "Du kannst ein integriertes Modell nicht bearbeiten." - configuration: - disable_embeddings: "Du musst zuerst „KI-Einbettungen aktiviert“ deaktivieren." - invalid_config: "Du hast eine ungültige Option ausgewählt." - choose_model: "Lege zuerst „ausgewähltes KI-Einbettungsmodell“ fest." - llm_models: - missing_provider_param: "%{param} darf nicht leer sein" - bedrock_invalid_url: "Bitte fülle alle Felder aus, um dieses Modell zu verwenden." - ai_staff_action_logger: - updated: "aktualisiert" - set: "gesetzt" - removed: "entfernt" - errors: - quota_exceeded: "Du hast das Kontingent für dieses Modell überschritten. Bitte versuche es erneut in %{relative_time}." - quota_required: "Du musst die maximale Anzahl an Token oder Verwendungen für dieses Modell angeben" - no_query_specified: Der Abfrageparameter ist erforderlich, bitte gib ihn an. - no_user_for_persona: Die angegebene Persona hat keinen Benutzer, der mit ihr verbunden ist. - persona_not_found: Die angegebene Persona existiert nicht. Überprüfe die Parameter persona_name oder persona_id. - no_user_specified: Der Benutzername oder der Parameter user_unique_id ist erforderlich. Bitte gib ihn an. - user_not_found: Der angegebene Benutzer existiert nicht. Überprüfe den Parameter username. - persona_disabled: Die angegebene Persona ist deaktiviert. Überprüfe die Parameter persona_name oder persona_id. - no_default_llm: Die Persona muss eine default_llm definiert haben. - user_not_allowed: Der Benutzer darf nicht am Thema teilnehmen. - prompt_message_length: Die Nachricht %{idx} hat die 1000-Zeichen-Grenze überschritten. - persona_already_exists: Eine Persona mit dem Namen %{name} existiert bereits. - custom_tool_exists: - one: "Ein benutzerdefiniertes Tool mit dem Namen %{names} existiert bereits." - other: "Benutzerdefinierte Tools mit den Namen %{names} existieren bereits." - dashboard: - problem: - ai_llm_status: "Bei dem LLM-Modell: %{model_name} treten Probleme auf. Bitte überprüfe die Konfigurationsseite des Modells." diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml deleted file mode 100644 index bbb69301..00000000 --- a/config/locales/server.el.yml +++ /dev/null @@ -1,18 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -el: - reports: - overall_sentiment: - yaxis: "Ημερομηνία" - discourse_ai: - ai_bot: - tool_summary: - search: "Αναζήτηση" - time: "Ώρα" - ai_staff_action_logger: - updated: "ενημερώθηκε" - removed: "αφαιρέθηκε" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml deleted file mode 100644 index 5a869659..00000000 --- a/config/locales/server.en.yml +++ /dev/null @@ -1,640 +0,0 @@ -en: - discourse_automation: - ai: - flag_types: - review: "Add post to review queue" - review_hide: "Add post to review queue and hide post" - spam: "Flag as spam and hide post" - spam_silence: "Flag as spam, hide post and silence user" - scriptables: - llm_tool_triage: - title: Triage posts using AI Tool - description: "Triage posts using custom logic in an AI tool" - llm_persona_triage: - title: Triage posts using AI Persona - description: "Respond to posts using a specific AI persona" - llm_triage: - title: Triage posts using AI - description: "Triage posts using a large language model" - flagged_post: | -
Response from the model:
-

%%LLM_RESPONSE%%

- Triggered by the %%AUTOMATION_NAME%% rule. - llm_report: - title: Periodic report using AI - description: "Periodic report based on a large language model" - site_settings: - discourse_ai_enabled: "Enable the discourse AI plugin." - ai_artifact_security: "The AI artifact system generates IFRAMEs with runnable code. Strict mode forces an extra click to run code. Lax mode runs code immediately. Hybrid mode allows user to supply data-ai-artifact-autorun to show right away. Disabled mode disables the artifact system." - ai_toxicity_enabled: "Enable the toxicity module." - ai_toxicity_inference_service_api_endpoint: "URL where the API is running for the toxicity module" - ai_toxicity_inference_service_api_key: "API key for the toxicity API" - ai_toxicity_inference_service_api_model: "Model to use for inference. Multilingual model works with Italian, French, Russian, Portuguese, Spanish and Turkish." - ai_toxicity_flag_automatically: "Automatically flag posts / chat messages that are above the configured thresholds." - ai_toxicity_flag_threshold_toxicity: "Toxicity: a rude, disrespectful, or unreasonable comment that is somewhat likely to make you leave a discussion or give up on sharing your perspective" - ai_toxicity_flag_threshold_severe_toxicity: "Severe Toxicity: a very hateful, aggressive, or disrespectful comment that is very likely to make you leave a discussion or give up on sharing your perspective" - ai_toxicity_flag_threshold_obscene: "Obscene" - ai_toxicity_flag_threshold_identity_attack: "Identity Attack" - ai_toxicity_flag_threshold_insult: "Insult" - ai_toxicity_flag_threshold_threat: "Threat" - ai_toxicity_flag_threshold_sexual_explicit: "Sexual Explicit" - ai_toxicity_groups_bypass: "Users on those groups will not have their posts classified by the toxicity module." - - ai_sentiment_enabled: "Enable the sentiment module." - ai_sentiment_inference_service_api_endpoint: "URL where the API is running for the sentiment module" - ai_sentiment_inference_service_api_key: "API key for the sentiment API" - ai_sentiment_models: "Models to use for inference. Sentiment classifies post on the positive/neutral/negative space. Emotion classifies on the anger/disgust/fear/joy/neutral/sadness/surprise space." - - ai_nsfw_detection_enabled: "Enable the NSFW module." - ai_nsfw_inference_service_api_endpoint: "URL where the API is running for the NSFW module" - ai_nsfw_inference_service_api_key: "API key for the NSFW API" - ai_nsfw_flag_automatically: "Automatically flag NSFW posts that are above the configured thresholds." - ai_nsfw_flag_threshold_general: "General Threshold for an image to be considered NSFW." - ai_nsfw_flag_threshold_drawings: "Threshold for a drawing to be considered NSFW." - ai_nsfw_flag_threshold_hentai: "Threshold for an image classified as hentai to be considered NSFW." - ai_nsfw_flag_threshold_porn: "Threshold for an image classified as porn to be considered NSFW." - ai_nsfw_flag_threshold_sexy: "Threshold for an image classified as sexy to be considered NSFW." - ai_nsfw_models: "Models to use for NSFW inference." - - ai_spam_detection_enabled: "Enable the AI spam detection module" - - ai_openai_api_key: "API key for OpenAI API. ONLY used for Image creation and edits. For GPT use the LLM config tab" - ai_openai_image_generation_url: "URL for OpenAI image generation API" - ai_openai_image_edit_url: "URL for OpenAI image edit API" - - ai_helper_enabled: "Enable the AI helper." - composer_ai_helper_allowed_groups: "Users on these groups will see the AI helper button in the composer." - ai_helper_allowed_in_pm: "Enable the composer's AI helper in PMs." - ai_helper_model: "Model to use for the AI helper." - ai_helper_custom_prompts_allowed_groups: "Users on these groups will see the custom prompt option in the AI helper." - ai_helper_automatic_chat_thread_title_delay: "Delay in minutes before the AI helper automatically sets the chat thread title." - ai_helper_automatic_chat_thread_title: "Automatically set the chat thread titles based on thread contents." - ai_helper_illustrate_post_model: "Model to use for the composer AI helper's illustrate post feature" - ai_helper_enabled_features: "Select the features to enable in the AI helper." - post_ai_helper_allowed_groups: "User groups allowed to access AI Helper features in posts" - ai_helper_image_caption_model: "Select the model to use for generating image captions" - ai_auto_image_caption_allowed_groups: "Users on these groups can toggle automatic image captioning." - - ai_embeddings_selected_model: "Use the selected model for generating embeddings." - ai_embeddings_generate_for_pms: "Generate embeddings for personal messages." - ai_embeddings_semantic_related_topics_enabled: "Use Semantic Search for related topics." - ai_embeddings_semantic_related_topics: "Maximum number of topics to show in related topic section." - ai_embeddings_backfill_batch_size: "Number of embeddings to backfill every 15 minutes." - ai_embeddings_semantic_search_enabled: "Enable full-page semantic search." - ai_embeddings_semantic_quick_search_enabled: "Enable semantic search option in search menu popup." - ai_embeddings_semantic_related_include_closed_topics: "Include closed topics in semantic search results" - ai_embeddings_semantic_search_hyde_model: "Model used to expand keywords to get better results during a semantic search" - ai_embeddings_per_post_enabled: Generate embeddings for each post - - ai_summarization_enabled: "Enable the summarize feature" - ai_summarization_model: "Model to use for summarization" - ai_summarization_persona: "Persona to use for summarize feature" - ai_custom_summarization_allowed_groups: "Groups allowed to use create new summaries." - ai_pm_summarization_allowed_groups: "Groups allowed to create and view summaries in PMs." - ai_summary_gists_enabled: "Generate brief summaries of latest replies in topics automatically" - ai_summary_gists_allowed_groups: "Groups allowed to see gists in the hot topics list." - ai_summary_backfill_maximum_topics_per_hour: "Number of topic summaries to backfill per hour." - - ai_bot_enabled: "Enable the AI Bot module." - ai_bot_enable_chat_warning: "Display a warning when PM chat is initiated. Can be overriden by editing the translation string: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "When the GPT Bot has access to the PM, it will reply to members of these groups." - ai_bot_debugging_allowed_groups: "Allow these groups to see a debug button on posts which displays the raw AI request and response" - ai_bot_public_sharing_allowed_groups: "Allow these groups to share AI personal messages with the public via a unique publicly available link. Note: if your site requires login, shares will also require login." - ai_bot_add_to_header: "Display a button in the header to start a PM with a AI Bot" - ai_bot_github_access_token: "GitHub access token for use with GitHub AI tools (required for search support)" - - ai_stability_api_key: "API key for the stability.ai API" - ai_stability_engine: "Image generation engine to use for the stability.ai API" - ai_stability_api_url: "URL for the stability.ai API" - - ai_google_custom_search_api_key: "API key for the Google Custom Search API see: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX for Google Custom Search API" - - ai_discord_search_enabled: "Enables the Discord search feature" - ai_discord_app_id: "The ID of the Discord application you would like to connect Discord search to" - ai_discord_app_public_key: "The public key of the Discord application you would like to connect Discord search to" - ai_discord_search_mode: "Select the search mode to use for Discord search" - ai_discord_search_persona: "The persona to use for Discord search." - ai_discord_allowed_guilds: "Discord guilds (servers) where the bot is allowed to search" - ai_bot_enable_dedicated_ux: "Allow for full screen bot interface, instead of a PM" - - ai_translation_enabled: "Enables the AI translation feature" - ai_translation_model: "The model to use for translation. This model must support translation. Personas can override this setting." - ai_translation_backfill_limit_to_public_content: "When enabled, only content in public categories will be translated. When disabled, content in group PMs and private categories will also be sent for translation." - ai_translation_max_post_length: "The maximum length of a post to be translated. Posts longer than this will not be translated." - ai_translation_backfill_max_age_days: "The maximum age of a post and topic to be translated. Posts and topics older than this will not be translated. 0 disables backfilling, but will not disable translation of new posts." - - reviewables: - reasons: - flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic. - flagged_by_nsfw: The AI plugin flagged this after classifying at least one of the attached images as NSFW. - - reports: - sentiment_analysis: - title: "Sentiment analysis" - description: "This report provides sentiment analysis for posts, grouped by category, with positive, negative, and neutral scores for each post and category." - overall_sentiment: - title: "Overall sentiment" - description: 'The chart compares the number of posts classified as either positive or negative. These are calculated when positive or negative scores > the set threshold score. This means neutral posts are not shown. Personal messages (PMs) are also excluded. Classified with "cardiffnlp/twitter-roberta-base-sentiment-latest"' - xaxis: "Positive(%)" - yaxis: "Date" - emotion_admiration: - title: 🤩 Admiration - description: "Posts classified with the emotion admiration via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_amusement: - title: 😄 Amusement - description: "Posts classified with the emotion amusement via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_anger: - title: 😠 Anger - description: "Posts classified with the emotion anger via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_annoyance: - title: 😒 Annoyance - description: "Posts classified with the emotion annoyance via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_approval: - title: 👍 Approval - description: "Posts classified with the emotion approval via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_caring: - title: 🤗 Caring - description: "Posts classified with the emotion caring via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_confusion: - title: 😕 Confusion - description: "Posts classified with the emotion confusion via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_curiosity: - title: 🤔 Curiosity - description: "Posts classified with the emotion curiosity via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_desire: - title: 😍 Desire - description: "Posts classified with the emotion desire via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_disappointment: - title: 😞 Disappointment - description: "Posts classified with the emotion disappointment via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_disapproval: - title: 👎 Disapproval - description: "Posts classified with the emotion disapproval via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_disgust: - title: 🤢 Disgust - description: "Posts classified with the emotion disgust via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_embarrassment: - title: 😳 Embarrassment - description: "Posts classified with the emotion embarrassment via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_excitement: - title: 🤪 Excitement - description: "Posts classified with the emotion excitement via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_fear: - title: 😨 Fear - description: "Posts classified with the emotion fear via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_gratitude: - title: 🙏 Gratitude - description: "Posts classified with the emotion gratitude via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_grief: - title: 😢 Grief - description: "Posts classified with the emotion grief via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_joy: - title: 😊 Joy - description: "Posts classified with the emotion joy via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_love: - title: ❤️ Love - description: "Posts classified with the emotion love via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_nervousness: - title: 😰 Nervousness - description: "Posts classified with the emotion nervousness via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_neutral: - title: 😐 Neutral - description: "Posts classified with the emotion neutral via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_optimism: - title: 🌟 Optimism - description: "Posts classified with the emotion optimism via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_pride: - title: 🦁 Pride - description: "Posts classified with the emotion pride via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_realization: - title: 💡 Realization - description: "Posts classified with the emotion realization via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_relief: - title: 😌 Relief - description: "Posts classified with the emotion relief via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_remorse: - title: 😔 Remorse - description: "Posts classified with the emotion remorse via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_sadness: - title: 😭 Sadness - description: "Posts classified with the emotion sadness via AI, using the model 'SamLowe/roberta-base-go_emotions'." - emotion_surprise: - title: 😲 Surprise - description: "Posts classified with the emotion surprise via AI, using the model 'SamLowe/roberta-base-go_emotions'." - - discourse_ai: - ai_artifact: - errors: - max_keys_exceeded: - one: "You can only have %{count} key in the artifact." - other: "You can only have %{count} keys in the artifact." - link: "Show in new tab" - copy_embed: "Copy embed" - view_source: "View Source" - view_changes: "View Changes" - change_description: "Change Description" - copied: "Copied to clipboard" - unknown_model: "Unknown AI model" - - tools: - custom_name: "%{name} (custom)" - presets: - browse_web_jina: - name: "Browse web (jina.ai)" - exchange_rate: - name: "Exchange rate" - stock_quote: - name: "Stock quote (AlphaVantage)" - image_generation: - name: "Flux image generator (Together.ai)" - empty_tool: - name: "Start from blank..." - name: - characters: "must only include numbers, letters, and underscores" - - ai_helper: - errors: - completion_request_failed: "Something went wrong while trying to provide suggestions. Please, try again." - prompts: - translate: Translate to %{language} - generate_titles: Suggest topic titles - proofread: Proofread text - markdown_table: Generate Markdown table - custom_prompt: "Custom Prompt" - explain: "Explain" - illustrate_post: "Illustrate Post" - replace_dates: "Smart dates" - painter: - attribution: - stable_diffusion_xl: "Image by Stable Diffusion XL" - dall_e_3: "Image by DALL-E 3" - image_caption: - attribution: "Captioned by AI" - - share_ai: - read_more: "Read full transcript" - onebox_title: "AI Conversation with %{llm_name}" - formatted_excerpt: "AI Conversation with %{llm_name}:\n %{excerpt}" - title: "%{title} - AI Conversation - %{site_name}" - errors: - not_allowed: "You are not allowed to share this topic" - other_people_in_pm: "Personal messages with other humans cannot be shared publicly" - other_content_in_pm: "Personal messages containing posts from other people cannot be shared publicly" - failed_to_share: "Failed to share the conversation" - conversation_deleted: "Conversation share deleted successfully" - spam_detection: - flag_reason: "Flagged as spam by Discourse AI" - silence_reason: "User silenced automatically by Discourse AI" - invalid_error_type: "Invalid error type provided" - unexpected: "An unexpected error occured" - bot_user_update_failed: "Failed to update the spam scanning bot user" - configuration_missing: "The AI spam detection configuration is missing. Add configuration in the 'Admin > Plugins > Discourse AI > Spam' before enabling." - logging_subject: "Spam detection" - - ai_bot: - reply_error: "Sorry, it looks like our system encountered an unexpected issue while trying to reply.\n\n[details='Error details']\n%{details}\n[/details]" - default_pm_prefix: "[Untitled AI bot PM]" - thinking: "Thinking..." - personas: - default_llm_required: "Default LLM model is required prior to enabling Chat" - cannot_delete_system_persona: "System personas cannot be deleted, please disable it instead" - cannot_edit_system_persona: "System personas can only be renamed, you may not edit tools or system prompt, instead disable and make a copy" - cannot_have_duplicate_tools: "Can not have duplicate tools" - github_helper: - name: "GitHub Helper" - description: "AI Bot specialized in assisting with GitHub-related tasks and questions" - general: - name: Forum Helper - description: "General purpose AI Bot capable of performing various tasks" - artist: - name: Artist - description: "AI Bot specialized in generating images" - designer: - name: Designer - description: "AI Bot specialized in generating and editing images" - forum_researcher: - name: Forum Researcher - description: "AI Bot specialized in deep research for the forum" - sql_helper: - name: SQL Helper - description: "AI Bot specialized in helping craft SQL queries on this Discourse instance" - settings_explorer: - name: Settings Explorer - description: "AI Bot specialized in helping explore Discourse site settings" - researcher: - name: Web Researcher - description: "AI Bot with Google access that can both search and read web pages" - creative: - name: Creative - description: "AI Bot with no external integrations specialized in creative tasks" - dall_e3: - name: "DALL-E 3" - description: "AI Bot specialized in generating images using DALL-E 3" - discourse_helper: - name: "Discourse Helper" - description: "AI Bot specialized in helping with Discourse related tasks" - web_artifact_creator: - name: "Web Artifact Creator" - description: "AI Bot specialized in creating interactive web artifacts" - summarizer: - name: "Summarizer" - description: "Default persona used to power AI summaries" - short_summarizer: - name: "Summarizer (short form)" - description: "Default persona used to power AI short summaries for topic lists' items" - concept_finder: - name: "Concept Finder" - description: "AI Bot specialized in identifying concepts and themes in content" - concept_matcher: - name: "Concept Matcher" - description: "AI Bot specialized in matching content against existing concepts" - concept_deduplicator: - name: "Concept Deduplicator" - description: "AI Bot specialized in deduplicating concepts" - custom_prompt: - name: "Custom prompt" - description: "Default persona powering the Helper's custom prompt feature" - smart_dates: - name: "Smart dates" - description: "Default persona powering the Helper's smart dates feature" - markdown_table_generator: - name: "Markdown table generator" - description: "Default persona powering the Helper's generate Markdown table feature" - post_illustrator: - name: "Post illustrator" - description: "Generates StableDiffusion prompts to power the Helper's illustrate post feature" - proofreader: - name: "Proofreader" - description: "Default persona powering the Helper's proofread text feature" - titles_generator: - name: "Titles generator" - description: "Default persona powering the Helper's suggest topic titles feature" - tutor: - name: "Tutor" - description: "Default persona powering the Helper's explain feature" - translator: - name: "Translator" - description: "Default persona powering the Helper's translator feature" - image_captioner: - name: "Image captions" - description: "Default persona powering the Helper's image caption feature" - locale_detector: - name: "Locale detector" - description: "Powers the translation feature by detecting the locale of a given text (posts, titles, etc.)" - post_raw_translator: - name: "Post translator" - description: "Powers the translation feature by translating posts containing Discourse Markdown" - topic_title_translator: - name: "Topic title translator" - description: "Powers the translation feature by translating topic titles" - short_text_translator: - name: "Short text translator" - description: "Powers the translation feature by as a generic text translator, used for short texts like category names or tags" - spam_detector: - name: "Spam detector" - description: "Default persona powering our Spam detection feature" - content_creator: - name: "Content creator" - description: "Default persona powering HyDE search" - report_runner: - name: "Report runner" - description: "Default persona used in the report automation script" - - topic_not_found: "Summary unavailable, topic not found!" - summarizing: "Summarizing topic" - searching: "Searching for: '%{query}'" - tool_options: - researcher: - researcher_llm: - name: "LLM" - description: "Language model to use for research (default to current persona's LLM)" - max_tokens_per_batch: - name: "Maximum tokens per batch" - description: "Maximum number of tokens to use for each batch in the research" - max_tokens_per_post: - name: "Maximum tokens per post" - description: "Maximum number of tokens to use for each post in the research" - max_results: - name: "Maximum number of results" - description: "Maximum number of results to include in a filter" - include_private: - name: "Include private" - description: "Include private topics in the filters" - create_artifact: - creator_llm: - name: "LLM" - description: "Language model to use for artifact creation" - update_artifact: - editor_llm: - name: "LLM" - description: "Language model to use for artifact edits" - update_algorithm: - name: "Update Algorithm" - description: "Ask LLM to fully replace, or use diff to update" - do_not_echo_artifact: - name: "Do Not Echo Artifact" - description: "Will limit costs however effectiveness of Artifact updates will be reduced" - google: - base_query: - name: "Base Search Query" - description: "Base query to use when searching. Examples: 'site:example.com' will only include results from example.com, before:2022-01-01 will only includes results from 2021 and earlier. This text is prepended to the search query." - read: - read_private: - name: "Read Private" - description: "Allow access to all topics user has access to (by default only public topics are included)" - search: - search_private: - name: "Search Private" - description: "Include all topics user has access to in search results (by default only public topics are included)" - max_results: - name: "Maximum number of results" - description: "Maximum number of results to include in the search - if empty default rules will be used and count will be scaled depending on model used. Highest value is 100." - base_query: - name: "Base Search Query" - description: "Base query to use when searching. Example: '#urgent' will prepend '#urgent' to the search query and only include topics with the urgent category or tag." - tool_summary: - read_artifact: "Read a web artifact" - update_artifact: "Update a web artifact" - create_artifact: "Create web artifact" - web_browser: "Browse Web" - github_search_files: "GitHub search files" - github_search_code: "GitHub code search" - github_file_content: "GitHub file content" - github_pull_request_diff: "GitHub pull request diff" - random_picker: "Random Picker" - categories: "List categories" - search: "Search" - tags: "List tags" - time: "Time" - summarize: "Summarize" - image: "Generate image" - google: "Search Google" - read: "Read topic" - setting_context: "Look up site setting context" - schema: "Look up database schema" - search_settings: "Searching site settings" - dall_e: "Generate image" - search_meta_discourse: "Search Meta Discourse" - javascript_evaluator: "Evaluate JavaScript" - create_image: "Creating image" - edit_image: "Editing image" - researcher: "Researching" - researcher_dry_run: "Preparing research" - tool_help: - read_artifact: "Read a web artifact using the AI Bot" - update_artifact: "Update a web artifact using the AI Bot" - create_artifact: "Create a web artifact using the AI Bot" - web_browser: "Browse web page using the AI Bot" - github_search_code: "Search for code in a GitHub repository" - github_search_files: "Search for files in a GitHub repository" - github_file_content: "Retrieve content of files from a GitHub repository" - github_pull_request_diff: "Retrieve a GitHub pull request diff" - random_picker: "Pick a random number or a random element of a list" - categories: "List all publicly visible categories on the forum" - search: "Search all public topics on the forum" - tags: "List all tags on the forum" - time: "Find time in various time zones" - summary: "Summarize a topic" - image: "Generate image using Stable Diffusion" - create_image: "Generate image using Open AI GPT image model" - edit_image: "Edit image using Open AI GPT image model" - google: "Search Google for a query" - read: "Read public topic on the forum" - setting_context: "Look up site setting context" - schema: "Look up database schema" - search_settings: "Search site settings" - dall_e: "Generate image using DALL-E 3" - search_meta_discourse: "Search Meta Discourse" - javascript_evaluator: "Evaluate JavaScript" - researcher: "Research forum information using the AI Bot" - tool_description: - read_artifact: "Read a web artifact using the AI Bot" - update_artifact: "Updated a web artifact using the AI Bot" - create_artifact: "Created a web artifact: %{name} - %{specification}" - web_browser: "Reading %{url}" - github_search_files: "Searched for '%{keywords}' in %{repo}/%{branch}" - github_search_code: "Searched for '%{query}' in %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Retrieved content of %{file_paths} from %{repo_name}@%{branch}" - random_picker: "Picking from %{options}, picked: %{result}" - read: "Reading: %{title}" - time: "Time in %{timezone} is %{time}" - summarize: "Summarized %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "Found %{count} category" - other: "Found %{count} categories" - tags: - one: "Found %{count} tag" - other: "Found %{count} tags" - search: - one: "Found %{count} result for '%{query}'" - other: "Found %{count} results for '%{query}'" - search_meta_discourse: - one: "Found %{count} result for '%{query}'" - other: "Found %{count} results for '%{query}'" - google: - one: "Found %{count} result for '%{query}'" - other: "Found %{count} results for '%{query}'" - setting_context: "Reading context for: %{setting_name}" - schema: "%{tables}" - researcher_dry_run: - one: "Proposed goals: %{goals}\n\nFound %{count} post matching '%{filter}'" - other: "Proposed goals: %{goals}\n\nFound %{count} posts matching '%{filter}'" - researcher: - one: "Researching: %{goals}\n\nFound %{count} post matching '%{filter}'" - other: "Researching: %{goals}\n\nFound %{count} posts matching '%{filter}'" - search_settings: - one: "Found %{count} result for '%{query}'" - other: "Found %{count} results for '%{query}'" - discoveries: - continue_conversation: - title: "Discovery conversation: Search for %{query}" - raw: "In my search for %{query}, you showed me the following information:\n\n%{context}\n\nLet's continue the conversation." - - summarization: - configuration_hint: - one: "Configure the `%{setting}` setting first." - other: "Configure these settings first: %{settings}" - chat: - no_targets: "There were no messages during the selected period." - - sentiment: - reports: - overall_sentiment: "Overall sentiment (Positive - Negative)" - post_emotion: - sadness: "Sadness 😢" - surprise: "Surprise 😱" - neutral: "Neutral 😐" - fear: "Fear 😨" - anger: "Anger 😡" - joy: "Joy 😀" - disgust: "Disgust 🤢" - sentiment_analysis: - positive: "Positive" - negative: "Negative" - neutral: "Neutral" - - llm: - configuration: - create_llm: "You need to setup an LLM before enabling this feature" - disable_module_first: "You have to disable %{setting} first." - set_llm_first: "Set %{setting} first" - model_unreachable: "We couldn't get a response from this model. Check your settings first." - invalid_seeded_model: "You can't use this model with this feature" - invalid_persona_response_format: "The selected persona must have a response format with a boolean field names \"spam\"" - must_select_model: "You must select a LLM first" - endpoints: - not_configured: "%{display_name} (not configured)" - configuration_hint: - one: "Make sure the `%{settings}` setting was configured." - other: "Make sure these settings were configured: %{settings}" - - delete_failed: - one: "We couldn't delete this model because %{settings} is using it. Update the setting and try again." - other: "We couldn't delete this model because %{settings} are using it. Update the settings and try again." - cannot_edit_builtin: "You can't edit a built-in model." - - personas: - malformed_examples: "The given examples have the wrong format." - - embeddings: - delete_failed: "This model is currently in use. Update the `ai embeddings selected model` first." - cannot_edit_builtin: "You can't edit a built-in model." - configuration: - disable_embeddings: "You have to disable 'ai embeddings enabled' first." - invalid_config: "You selected a invalid option." - choose_model: "Set 'ai embeddings selected model' first." - - llm_models: - missing_provider_param: "%{param} can't be blank" - bedrock_invalid_url: "Please complete all the fields to use this model." - - ai_staff_action_logger: - updated: "updated" - set: "set" - removed: "removed" - - errors: - quota_exceeded: "You have exceeded the quota for this model. Please try again in %{relative_time}." - quota_required: "You must specify maximum tokens or usages for this model" - no_query_specified: The query parameter is required, please specify it. - no_user_for_persona: The persona specified does not have a user associated with it. - persona_not_found: The persona specified does not exist. Check the persona_name or persona_id params. - no_user_specified: The username or the user_unique_id parameter is required, please specify it. - user_not_found: The user specified does not exist. Check the username param. - persona_disabled: The persona specified is disabled. Check the persona_name or persona_id params. - no_default_llm: The persona must have a default_llm defined. - user_not_allowed: The user is not allowed to participate in the topic. - prompt_message_length: The message %{idx} is over the 1000 character limit. - persona_already_exists: Persona with the name %{name} already exists. - custom_tool_exists: - one: "Custom tool with the name %{names} already exists." - other: "Custom tools with the names %{names} already exist." - dashboard: - problem: - ai_llm_status: "The LLM model: %{model_name} is encountering issues. Please check the model's configuration page." diff --git a/config/locales/server.en_GB.yml b/config/locales/server.en_GB.yml deleted file mode 100644 index 2d4fa180..00000000 --- a/config/locales/server.en_GB.yml +++ /dev/null @@ -1,7 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -en_GB: diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml deleted file mode 100644 index 660b623e..00000000 --- a/config/locales/server.es.yml +++ /dev/null @@ -1,445 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -es: - discourse_automation: - ai: - flag_types: - review: "Añadir publicación a la cola de revisión" - spam: "Marcar como spam y ocultar publicación" - spam_silence: "Marcar como spam, ocultar publicación y silenciar al usuario" - scriptables: - llm_triage: - title: Triaje de publicaciones mediante IA - description: "Triaje de publicaciones utilizando un gran modelo lingüístico" - flagged_post: | -
Respuesta del modelo:
-

%%LLM_RESPONSE%%

- Activada por la regla. - llm_report: - title: Informe periódico mediante IA - description: "Informe periódico basado en un modelo lingüístico de gran tamaño" - site_settings: - discourse_ai_enabled: "Activar el complemento de IA de Discourse." - ai_toxicity_enabled: "Activar el módulo de toxicidad." - ai_toxicity_inference_service_api_endpoint: "URL donde se ejecuta la API para el módulo de toxicidad" - ai_toxicity_inference_service_api_key: "Clave API para la API de toxicidad" - ai_toxicity_inference_service_api_model: "Modelo que se utilizará para la inferencia. El modelo multilingüe funciona con italiano, francés, ruso, portugués, español y turco." - ai_toxicity_flag_automatically: "Denunciar automáticamente las publicaciones / mensajes de chat que superen los umbrales configurados." - ai_toxicity_flag_threshold_toxicity: "Toxicidad: un comentario grosero, irrespetuoso o irrazonable que tiene ciertas probabilidades de hacerte abandonar una discusión o renunciar a compartir tu punto de vista." - ai_toxicity_flag_threshold_severe_toxicity: "Toxicidad grave: un comentario muy odioso, agresivo o irrespetuoso que es muy probable que te haga abandonar una discusión o renunciar a compartir tu punto de vista." - ai_toxicity_flag_threshold_obscene: "Obsceno" - ai_toxicity_flag_threshold_identity_attack: "Ataque de identidad" - ai_toxicity_flag_threshold_insult: "Insulto" - ai_toxicity_flag_threshold_threat: "Amenaza" - ai_toxicity_flag_threshold_sexual_explicit: "Sexual explícito" - ai_toxicity_groups_bypass: "Los usuarios de esos grupos no verán sus mensajes clasificados por el módulo de toxicidad." - ai_sentiment_enabled: "Activar el módulo de sentimientos." - ai_sentiment_inference_service_api_endpoint: "URL donde se ejecuta la API para el módulo de sentimientos" - ai_sentiment_inference_service_api_key: "Clave API para la API de sentimientos" - ai_sentiment_models: "Modelos que se utilizarán para la inferencia. El sentimiento clasifica las publicaciones en el espacio positivo/neutral/negativo. La emoción se clasifica en el espacio ira/asco/miedo/alegría/neutro/tristeza/sorpresa." - ai_nsfw_detection_enabled: "Activar el módulo NSFW." - ai_nsfw_inference_service_api_endpoint: "URL donde se ejecuta la API para el módulo NSFW" - ai_nsfw_inference_service_api_key: "Clave API para la API NSFW" - ai_nsfw_flag_automatically: "Denunciar automáticamente las publicaciones NSFW que superen los umbrales configurados." - ai_nsfw_flag_threshold_general: "Umbral general para que una imagen se considere NSFW." - ai_nsfw_flag_threshold_drawings: "Umbral para que un dibujo se considere NSFW." - ai_nsfw_flag_threshold_hentai: "Umbral para que una imagen clasificada como hentai sea considerada NSFW." - ai_nsfw_flag_threshold_porn: "Umbral para que una imagen clasificada como porno sea considerada NSFW." - ai_nsfw_flag_threshold_sexy: "Umbral para que una imagen clasificada como sexy se considere NSFW." - ai_nsfw_models: "Modelos que se utilizarán para la inferencia NSFW." - ai_helper_enabled: "Activar el ayudante de IA." - composer_ai_helper_allowed_groups: "Los usuarios de estos grupos verán el botón del asistente de IA en el compositor." - ai_helper_allowed_in_pm: "Activar el asistente de IA del compositor en los MP." - ai_helper_model: "Modelo que se utilizará para el asistente de IA." - ai_helper_custom_prompts_allowed_groups: "Los usuarios de estos grupos verán la opción de aviso personalizado en el ayudante de IA." - ai_helper_automatic_chat_thread_title_delay: "Retraso en minutos antes de que el ayudante de la IA establezca automáticamente el título del hilo del chat." - ai_helper_automatic_chat_thread_title: "Establecer automáticamente los títulos de los hilos del chat en función de su contenido." - ai_helper_illustrate_post_model: "Modelo a utilizar para la función ilustrar publicación del ayudante de IA del compositor" - ai_helper_enabled_features: "Selecciona las funciones que quieres activar en el ayudante de IA." - post_ai_helper_allowed_groups: "Grupos de usuarios autorizados a acceder a las funciones del ayudante de IA en las publicaciones" - ai_helper_image_caption_model: "Selecciona el modelo que se utilizará para generar pies de foto" - ai_auto_image_caption_allowed_groups: "Los usuarios de estos grupos pueden activar el subtitulado automático de imágenes." - ai_embeddings_selected_model: "Utiliza el modelo seleccionado para generar incrustaciones." - ai_embeddings_generate_for_pms: "Generar incrustaciones para mensajes personales." - ai_embeddings_semantic_related_topics_enabled: "Utilizar la Búsqueda semántica para temas relacionados." - ai_embeddings_semantic_related_topics: "Número máximo de temas que se mostrarán en la sección de temas relacionados." - ai_embeddings_backfill_batch_size: "Número de incrustaciones a rellenar cada 15 minutos." - ai_embeddings_semantic_search_enabled: "Activar la búsqueda semántica a página completa." - ai_embeddings_semantic_quick_search_enabled: "Activa la opción de búsqueda semántica en el menú emergente de búsqueda." - ai_embeddings_semantic_related_include_closed_topics: "Incluir temas cerrados en los resultados de la búsqueda semántica" - ai_embeddings_semantic_search_hyde_model: "Modelo utilizado para expandir palabras clave para obtener mejores resultados durante una búsqueda semántica" - ai_embeddings_per_post_enabled: Generar incrustaciones para cada publicación - ai_summarization_model: "Modelo que se utilizará para el resumen" - ai_custom_summarization_allowed_groups: "Grupos autorizados a utilizar la creación de nuevos resúmenes." - ai_pm_summarization_allowed_groups: "Grupos autorizados a crear y ver resúmenes en MPs." - ai_summary_gists_enabled: "Genera automáticamente breves resúmenes de las últimas respuestas en los temas" - ai_summary_gists_allowed_groups: "Grupos autorizados a ver fragmentos en la lista de temas candentes." - ai_summary_backfill_maximum_topics_per_hour: "Número de resúmenes de temas a completar por hora." - ai_bot_enabled: "Activar el módulo AI Bot." - ai_bot_enable_chat_warning: "Muestra una advertencia cuando se inicia el chat de MP. Se puede anular editando la cadena de traducción: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Cuando el Bot GPT tenga acceso al MP, responderá a los miembros de estos grupos." - ai_bot_debugging_allowed_groups: "Permitir que estos grupos vean un botón de depuración en las publicaciones que muestre la solicitud y la respuesta sin procesar de la IA." - ai_bot_public_sharing_allowed_groups: "Permitir que estos grupos compartan mensajes personales de IA con el público a través de un enlace único disponible públicamente. Nota: si tu sitio requiere inicio de sesión, los mensajes compartidos también requerirán inicio de sesión." - ai_bot_add_to_header: "Mostrar un botón en el encabezado para iniciar un MP con un bot de IA" - ai_bot_github_access_token: "Token de acceso a GitHub para utilizarlo con las herramientas de IA de GitHub (necesario para la compatibilidad con búsquedas)" - ai_stability_api_key: "Clave API para la API de stability.ai" - ai_stability_engine: "Motor de generación de imágenes que se utilizará para la API stability.ai" - ai_stability_api_url: "URL para la API de stability.ai" - ai_google_custom_search_api_key: "Clave API para la API de búsqueda personalizada de Google, consulta: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX para la API de búsqueda personalizada de Google" - reviewables: - reasons: - flagged_by_toxicity: El plugin de IA lo denunció tras clasificarlo como tóxico. - flagged_by_nsfw: El plugin de IA denunció esto después de clasificar al menos una de las imágenes adjuntas como NSFW. - reports: - overall_sentiment: - title: "Sentimiento general" - description: 'El gráfico compara el número de publicaciones clasificadas como positivas o negativas. Se calculan cuando las puntuaciones positivas o negativas superan la puntuación umbral establecida. Esto significa que no se muestran las publicaciones neutrales. También se excluyen los mensajes personales (MP). Clasificados con «cardiffnlp/twitter-roberta-base-sentiment-latest»' - xaxis: "Positivas(%)" - yaxis: "Fecha" - emotion_admiration: - title: "\U0001F929 Admiración" - description: "Publicaciones clasificadas con la emoción Admiración mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_amusement: - title: "\U0001F604 Diversión" - description: "Publicaciones clasificadas con la emoción Diversión mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_anger: - title: "\U0001F620 Enfado" - description: "Publicaciones clasificadas con la emoción Ira mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_annoyance: - title: "\U0001F612 Molestia" - description: "Publicaciones clasificadas con la emoción Molestia mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_approval: - title: "\U0001F44D Aprobación" - description: "Mensajes clasificados con la emoción Aprobación mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_caring: - title: "\U0001F917 Cuidado" - description: "Publicaciones clasificadas con la emoción Cuidado mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_confusion: - title: "\U0001F615 Confusión" - description: "Publicaciones clasificadas con la emoción Confusión mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_curiosity: - title: "\U0001F914 Curiosidad" - description: "Publicaciones clasificadas con la emoción Curiosidad mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_desire: - title: "\U0001F60D Deseo" - description: "Publicaciones clasificadas con la emoción Deseo mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_disappointment: - title: "\U0001F61E Decepción" - description: "Publicaciones clasificadas con la emoción Decepción mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_disapproval: - title: "\U0001F44E Desaprobación" - description: "Publicaciones clasificadas con la emoción Desaprobación mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_disgust: - title: "\U0001F922 Asco" - description: "Publicaciones clasificadas con la emoción Asco mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_embarrassment: - title: "\U0001F633 Vergüenza" - description: "Publicaciones clasificadas con la emoción Vergüenza mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_excitement: - title: "\U0001F92A Excitación" - description: "Publicaciones clasificadas con la emoción Excitación mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_fear: - title: "\U0001F628 Miedo" - description: "Publicaciones clasificadas con la emoción Miedo mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_gratitude: - title: "\U0001F64F Gratitud" - description: "Publicaciones clasificadas con la emoción Gratitud mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_grief: - title: "\U0001F622 Duelo" - description: "Publicaciones clasificadas con la emoción Duelo mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_joy: - title: "\U0001F60A Alegría" - description: "Publicaciones clasificadas con la emoción Alegría mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_love: - title: '❤️ Amor' - description: "Publicaciones clasificadas con la emoción Amor mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_nervousness: - title: "\U0001F630 Nerviosismo" - description: "Publicaciones clasificadas con la emoción Nerviosismo mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_neutral: - title: "\U0001F610 Neutro" - description: "Publicaciones clasificadas con la emoción Neutro mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_optimism: - title: "\U0001F31F Optimismo" - description: "Publicaciones clasificadas con la emoción Optimismo mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_pride: - title: "\U0001F981 Orgullo" - description: "Publicaciones clasificadas con la emoción Orgullo mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_realization: - title: "\U0001F4A1 Realización" - description: "Mensajes clasificados con la emoción Realización mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_relief: - title: "\U0001F60C Alivio" - description: "Publicaciones clasificadas con la emoción Alivio mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_remorse: - title: "\U0001F614 Remordimiento" - description: "Publicaciones clasificadas con la emoción Remordimiento mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_sadness: - title: "\U0001F62D Tristeza" - description: "Publicaciones clasificadas con la emoción Tristeza mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - emotion_surprise: - title: "\U0001F632 Sorpresa" - description: "Publicaciones clasificadas con la emoción Sorpresa mediante IA, utilizando el modelo 'SamLowe/roberta-base-go_emotions'." - discourse_ai: - ai_artifact: - view_source: "Ver fuente" - view_changes: "Ver cambios" - unknown_model: "Modelo de IA desconocido" - tools: - custom_name: "%{name} (personalizado)" - presets: - browse_web_jina: - name: "Navegar por la web (jina.ai)" - exchange_rate: - name: "Tipo de cambio" - stock_quote: - name: "Cotización de acciones (AlphaVantage)" - image_generation: - name: "Generador de imágenes de flujo (Together.ai)" - empty_tool: - name: "Empieza desde cero..." - ai_helper: - errors: - completion_request_failed: "Algo ha ido mal al intentar proporcionar sugerencias. Inténtalo de nuevo." - prompts: - translate: Traducir a %{language} - generate_titles: Sugerir títulos de temas - proofread: Corregir el texto - markdown_table: Generar tabla Markdown - custom_prompt: "Aviso personalizado" - explain: "Explicar" - illustrate_post: "Ilustrar publicación" - replace_dates: "Fechas inteligentes" - painter: - attribution: - stable_diffusion_xl: "Imagen de Stable Diffusion XL" - dall_e_3: "Imagen de DALL-E 3" - image_caption: - attribution: "Subtitulado por IA" - share_ai: - read_more: "Leer la transcripción completa" - onebox_title: "Conversación de IA con %{llm_name}" - formatted_excerpt: "Conversación de IA con %{llm_name}:\n %{excerpt}" - title: "%{title} - Conversación IA - %{site_name}" - errors: - not_allowed: "No tienes permiso para compartir este tema." - other_people_in_pm: "Los mensajes personales con otros humanos no se pueden compartir públicamente" - other_content_in_pm: "Los mensajes personales que contengan mensajes de otras personas no pueden compartirse públicamente" - failed_to_share: "No se pudo compartir la conversación" - conversation_deleted: "La conversación compartida se eliminó correctamente" - spam_detection: - flag_reason: "Marcado como correo no deseado por Discourse AI" - silence_reason: "Usuario silenciado automáticamente por Discourse AI" - invalid_error_type: "Tipo de error no válido proporcionado" - unexpected: "Se ha producido un error inesperado" - bot_user_update_failed: "Error al actualizar el usuario bot de escaneo de correo no deseado" - ai_bot: - reply_error: "Lo sentimos, parece que nuestro sistema ha encontrado un problema inesperado al intentar responder.\n\n[details='Detalles del error']\n%{details}\n[/details]" - default_pm_prefix: "[MP de bot de IA sin título]" - personas: - default_llm_required: "Se requiere el modelo LLM predeterminado antes de activar el Chat" - cannot_delete_system_persona: "Las personas del sistema no se pueden eliminar, desactívalas en su lugar" - cannot_edit_system_persona: "Las personas del sistema solo se pueden renombrar, no puedes editar las herramientas ni el aviso del sistema, en su lugar deshabilita y haz una copia" - github_helper: - name: "Asistente de GitHub" - description: "Bot de IA especializado en ayudar con tareas y preguntas relacionadas con GitHub" - general: - name: Ayudante del foro - description: "Bot de IA de propósito general capaz de realizar diversas tareas" - artist: - name: Artista - description: "AI Bot especializado en generar imágenes" - sql_helper: - name: Ayudante de SQL - description: "Bot de IA especializado en ayudar a crear consultas SQL en esta instancia de Discourse" - settings_explorer: - name: Explorador de ajustes - description: "Bot de IA especializado en ayudar a explorar los ajustes del sitio Discourse" - creative: - name: Creativa - description: "Bot de IA sin integraciones externas especializado en tareas creativas" - dall_e3: - name: "DALL-E 3" - description: "Bot de IA especializado en generar imágenes usando DALL-E 3" - discourse_helper: - name: "Asistente de Discourse" - description: "Bot de IA especializado en ayudar con tareas relacionadas con Discourse" - web_artifact_creator: - name: "Creador de artefactos web" - description: "Bot de IA especializado en crear artefactos web interactivos" - custom_prompt: - name: "Instruccón personalizada" - smart_dates: - name: "Fechas inteligentes" - topic_not_found: "¡Resumen no disponible, tema no encontrado!" - summarizing: "Resumiendo tema" - searching: "Buscando: '%{query}'" - tool_options: - researcher: - max_results: - name: "Número máximo de resultados" - google: - base_query: - name: "Consulta de búsqueda básica" - description: "Consulta base que se utilizará en la búsqueda. Ejemplos: 'site:ejemplo.com' solo incluirá resultados de ejemplo.com, before:2022-01-01 solo incluirá resultados de 2021 y anteriores. Este texto se añade a la consulta de búsqueda." - read: - read_private: - name: "Leer privado" - description: "Permitir el acceso a todos los temas a los que el usuario tiene acceso (por defecto, solo se incluyen los temas públicos)" - search: - search_private: - name: "Buscar privado" - description: "Incluir todos los temas a los que el usuario tiene acceso en los resultados de búsqueda (por defecto, solo se incluyen los temas públicos)" - max_results: - name: "Número máximo de resultados" - description: "Número máximo de resultados que se incluirán en la búsqueda; si está vacío, se utilizarán las reglas por defecto y el recuento se escalará en función del modelo utilizado. El valor más alto es 100." - base_query: - name: "Consulta de búsqueda básica" - description: "Consulta base a utilizar en la búsqueda. Ejemplo: «#urgente» antepondrá «#urgente» a la consulta de búsqueda y solo incluirá temas con la categoría o etiqueta urgente." - tool_summary: - update_artifact: "Actualizar un artefacto web" - create_artifact: "Crear artefacto web" - web_browser: "Navegar por Internet" - github_search_files: "Archivos de búsqueda de GitHub" - github_search_code: "Búsqueda de código en GitHub" - github_file_content: "Contenido del archivo de GitHub" - github_pull_request_diff: "Diferencia de solicitud de extracción de GitHub" - random_picker: "Selector aleatorio" - categories: "Lista de categorías" - search: "Buscar" - tags: "Listar etiquetas" - time: "Hora" - summarize: "Resumir" - image: "Generar imagen" - google: "Buscar en Google" - read: "Leer tema" - setting_context: "Buscar contexto de ajuste del sitio" - schema: "Buscar esquema de base de datos" - search_settings: "Buscando los ajustes del sitio" - dall_e: "Generar imagen" - search_meta_discourse: "Buscar en Discourse Meta" - javascript_evaluator: "Evaluar JavaScript" - tool_help: - update_artifact: "Actualizar un artefacto web usando el bot de IA" - create_artifact: "Crear un artefacto web usando el bot de IA" - web_browser: "Navegar por la página web utilizando el bot de IA" - github_search_code: "Buscar código en un repositorio de GitHub" - github_search_files: "Buscar archivos en un repositorio de GitHub" - github_file_content: "Recuperar el contenido de los archivos de un repositorio de GitHub" - github_pull_request_diff: "Recuperar una diferencia de solicitud de extracción de GitHub" - random_picker: "Elige un número aleatorio o un elemento aleatorio de una lista" - categories: "Listar todas las categorías visibles públicamente en el foro" - search: "Buscar todos los temas públicos en el foro." - tags: "Listar todas las etiquetas en el foro" - time: "Encontrar hora en varias zonas horarias" - summary: "Resumir un tema" - image: "Generar imagen usando Stable Diffusion" - google: "Buscar en Google una consulta" - read: "Leer tema público en el foro." - setting_context: "Buscar contexto de ajuste del sitio" - schema: "Buscar esquema de base de datos" - search_settings: "Buscar ajustes del sitio" - dall_e: "Generar imagen usando DALL-E 3" - search_meta_discourse: "Buscar en Discourse Meta" - javascript_evaluator: "Evaluar JavaScript" - tool_description: - update_artifact: "Se actualizó un artefacto web utilizando el bot de IA" - web_browser: "Leyendo %{url}" - github_search_files: "Se buscó «%{keywords}» en %{repo}/%{branch}" - github_search_code: "Se buscó «%{query}» en %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Contenido recuperado de %{file_paths} de %{repo_name}@%{branch}" - random_picker: "Escogiendo entre %{options}, escogido: %{result}" - read: "Leyendo: %{title}" - time: "La hora en %{timezone} es %{time}" - summarize: "Resumido %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "Se encontró %{count} categoría" - other: "Se encontraron %{count} categorías" - tags: - one: "Se encontró %{count} etiqueta" - other: "Se encontraron %{count} etiquetas" - search: - one: "Se encontró %{count} resultado para '%{query}'" - other: "Se han encontrado %{count} resultados para '%{query}'" - search_meta_discourse: - one: "Se encontró %{count} resultado para '%{query}'" - other: "Se han encontrado %{count} resultados para '%{query}'" - google: - one: "Se encontró %{count} resultado para '%{query}'" - other: "Se han encontrado %{count} resultados para '%{query}'" - setting_context: "Leyendo contexto parar: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "Se encontró %{count} resultado para '%{query}'" - other: "Se han encontrado %{count} resultados para '%{query}'" - summarization: - configuration_hint: - one: "Configura primero el ajuste `%{setting}`." - other: "Configura primero estos ajustes: %{settings}" - chat: - no_targets: "No hubo mensajes durante el periodo seleccionado." - sentiment: - reports: - overall_sentiment: "Sentimiento general (positivo - negativo)" - post_emotion: - sadness: "Tristeza \U0001F622" - surprise: "Sorpresa \U0001F631" - neutral: "Neutro \U0001F610" - fear: "Miedo \U0001F628" - anger: "Enfado \U0001F621" - joy: "Alegría \U0001F600" - disgust: "Asco \U0001F922" - sentiment_analysis: - positive: "Positivo" - negative: "Negativo" - neutral: "Neutro" - llm: - configuration: - disable_module_first: "Tienes que desactivar primero %{setting}." - set_llm_first: "Establecer primero %{setting}" - model_unreachable: "No hemos podido obtener una respuesta de este modelo. Comprueba primero los ajustes." - invalid_seeded_model: "No puedes utilizar este modelo con esta característica" - must_select_model: "Primero debes seleccionar un LLM" - endpoints: - not_configured: "%{display_name} (no configurado)" - configuration_hint: - one: "Asegúrate de que se ha configurado el ajuste «%{settings}»." - other: "Asegúrate de que se han configurado estos ajustes: «%{settings}»." - delete_failed: - one: "No hemos podido eliminar este modelo porque %{settings} lo está usando. Actualiza el ajuse e inténtalo de nuevo." - other: "No hemos podido eliminar este modelo porque %{settings} lo está usando. Actualiza los ajustes e inténtalo de nuevo." - cannot_edit_builtin: "No puedes editar un modelo integrado." - embeddings: - delete_failed: "Este modelo está actualmente en uso. Actualiza primero el «modelo seleccionado de incrustaciones de IA»." - cannot_edit_builtin: "No puedes editar un modelo integrado." - configuration: - disable_embeddings: "Tienes que desactivar primero «incrustaciones de ia activadas»." - choose_model: "Establece primero «modelo seleccionado de incrustaciones de ia»." - llm_models: - missing_provider_param: "%{param} no puede estar en blanco" - bedrock_invalid_url: "Rellena todos los campos para utilizar este modelo." - ai_staff_action_logger: - updated: "actualizado" - removed: "eliminado" - errors: - quota_exceeded: "Has superado la cuota para este modelo. Inténtalo de nuevo en %{relative_time}." - quota_required: "Debes especificar los tokens o usos máximos para este modelo" - no_query_specified: El parámetro de consulta es obligatorio, especifícalo. - no_user_for_persona: La persona especificada no tiene ningún usuario asociado. - persona_not_found: La persona especificada no existe. Comprueba los parámetros persona_name o persona_id. - no_user_specified: El nombre de usuario o user_unique_id es obligatorio, por favor, especifícalo. - user_not_found: El usuario especificado no existe. Comprueba el parámetro username. - persona_disabled: La persona especificada está desactivada. Comprueba los parámetros persona_name o persona_id. - no_default_llm: La persona debe tener un default_llm definido. - user_not_allowed: El usuario no está autorizado a participar en el tema. - prompt_message_length: El mensaje %{idx} supera el límite de 1000 caracteres. diff --git a/config/locales/server.et.yml b/config/locales/server.et.yml deleted file mode 100644 index e4b16e4d..00000000 --- a/config/locales/server.et.yml +++ /dev/null @@ -1,23 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -et: - reports: - overall_sentiment: - yaxis: "Date" - emotion_neutral: - title: "\U0001F610 Neutraalne" - discourse_ai: - ai_bot: - tool_summary: - search: "Otsi" - time: "Aeg" - sentiment: - reports: - post_emotion: - neutral: "Neutraalne \U0001F610" - sentiment_analysis: - neutral: "Neutraalne" diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml deleted file mode 100644 index 1cc005fa..00000000 --- a/config/locales/server.fa_IR.yml +++ /dev/null @@ -1,39 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -fa_IR: - site_settings: - discourse_ai_enabled: "افزونه discourse AI را فعال کنید." - reports: - overall_sentiment: - yaxis: "تاریخ" - discourse_ai: - share_ai: - onebox_title: "گفتگوی هوش‌مصنوعی با %{llm_name}" - formatted_excerpt: "گفتگوی هوش‌مصنوعی با %{llm_name}:\n %{excerpt}" - title: "%{title} - گفتگوی هوش‌مصنوعی - %{site_name}" - ai_bot: - personas: - dall_e3: - name: "DALL-E 3" - tool_summary: - categories: "فهرست دسته‌بندی‌ها" - search: "جستجو" - tags: "فهرست برچسب‌ها" - time: "زمان" - summarize: "خلاصه کنید" - image: "تولید تصویر" - google: "جستجو در گوگل" - dall_e: "تولید تصویر" - tool_description: - summarize: "خلاصه شده %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - ai_staff_action_logger: - updated: "به روز شده" - removed: "پاک شد" diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml deleted file mode 100644 index 82e38546..00000000 --- a/config/locales/server.fi.yml +++ /dev/null @@ -1,445 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -fi: - discourse_automation: - ai: - flag_types: - review: "Lisää viesti tarkastusjonoon" - spam: "Liputa roskapostiksi ja piilota viesti" - spam_silence: "Liputa roskapostiksi, piilota viesti ja hiljennä käyttäjä" - scriptables: - llm_triage: - title: Luokittele viestit tekoälyllä - description: "Luokittele viestit suurella kielimallilla" - flagged_post: | -
Mallin vastaus::
-

%%LLM_RESPONSE%%

- Säännön %%AUTOMATION_NAME%% käynnistämä. - llm_report: - title: Säännöllinen raportti tekoälyllä - description: "Säännöllinen raportti laajan kielimallin perusteella" - site_settings: - discourse_ai_enabled: "Ota Discoursen tekoälylisäosa käyttöön." - ai_toxicity_enabled: "Ota toksisuusmoduuli käyttöön." - ai_toxicity_inference_service_api_endpoint: "URL-osoite, jossa toksisuusmoduulin API on käynnissä" - ai_toxicity_inference_service_api_key: "Toksisuus-APIn API-avain" - ai_toxicity_inference_service_api_model: "Päätelmien tekemiseen käytettävä malli. Monikielinen malli toimii italian, ranskan, venäjän, portugalin, espanjan ja turkin kielillä." - ai_toxicity_flag_automatically: "Liputa automaattisesti viestit ja chat-viestit, jotka ylittävät määritetyt rajat." - ai_toxicity_flag_threshold_toxicity: "Toksisuus: töykeä, epäkunnioittava tai kohtuuton kommentti, joka melko todennäköisesti saa sinut poistumaan keskustelusta tai jättämään näkökulmasi jakamatta" - ai_toxicity_flag_threshold_severe_toxicity: "Vakava toksisuus: erittäin vihamielinen, aggressiivinen tai epäkunnioittava kommentti, joka hyvin todennäköisesti saa sinut poistumaan keskustelusta tai jättämään näkökulmasi jakamatta" - ai_toxicity_flag_threshold_obscene: "Säädytön" - ai_toxicity_flag_threshold_identity_attack: "Identiteettihyökkäys" - ai_toxicity_flag_threshold_insult: "Loukkaus" - ai_toxicity_flag_threshold_threat: "Uhkaus" - ai_toxicity_flag_threshold_sexual_explicit: "Seksuaalinen" - ai_toxicity_groups_bypass: "Toksisuusmoduuli ei luokittele näiden ryhmien käyttäjien viestejä." - ai_sentiment_enabled: "Ota tunnemoduuli käyttöön." - ai_sentiment_inference_service_api_endpoint: "URL-osoite, jossa tunnemoduulin API on käynnissä" - ai_sentiment_inference_service_api_key: "Tunne-APIn API-avain" - ai_sentiment_models: "Päätelmien tekemiseen käytettävät mallit. Tunne luokittelee viestin positiivinen/neutraali/negatiivinen-skaalalla. Tunne luokittelee viha/inho/pelko/ilo/neutraali/suru/yllätys-skaalalla." - ai_nsfw_detection_enabled: "Ota NSFW-moduuli käyttöön." - ai_nsfw_inference_service_api_endpoint: "URL-osoite, jossa NSFW-moduulin API on käynnissä" - ai_nsfw_inference_service_api_key: "NSFW-APIn API-avain" - ai_nsfw_flag_automatically: "Liputa automaattisesti NSFW-viestit, jotka ylittävät määritetyt rajat." - ai_nsfw_flag_threshold_general: "Yleinen raja, jolloin kuvaa pidetään NSFW:nä." - ai_nsfw_flag_threshold_drawings: "Raja, jolloin piirrosta pidetään NSFW:nä." - ai_nsfw_flag_threshold_hentai: "Raja, jolloin hentaiksi luokiteltua kuvaa pidetään NSFW:nä." - ai_nsfw_flag_threshold_porn: "Raja, jolloin pornoksi luokiteltua kuvaa pidetään NSFW:nä." - ai_nsfw_flag_threshold_sexy: "Raja, jolloin seksikkääksi luokiteltua kuvaa pidetään NSFW:nä." - ai_nsfw_models: "NSFW-päätelmiin käytettävät mallit." - ai_helper_enabled: "Ota tekoälyapuri käyttöön." - composer_ai_helper_allowed_groups: "Näiden ryhmien käyttäjät näkevät tekoälyavustajan painikkeen kirjoitustoiminnossa." - ai_helper_allowed_in_pm: "Ota kirjoitustoiminnon tekoälyavustaja käyttöön yksityisviesteissä." - ai_helper_model: "Tekoälyavustajassa käytettävä malli." - ai_helper_custom_prompts_allowed_groups: "Näiden ryhmien käyttäjät näkevät mukautetun kehotteen vaihtoehdon tekoälyapuohjelmassa." - ai_helper_automatic_chat_thread_title_delay: "Viive minuuteissa ennen kuin tekoälyapuohjelma asettaa chat-ketjun otsikon automaattisesti." - ai_helper_automatic_chat_thread_title: "Aseta chat-ketjujen otsikot automaattisesti ketjun sisällön perusteella." - ai_helper_illustrate_post_model: "Malli, jota käytetään tekstieditorin tekoälyapuohjelman viestin havainnollistamisominaisuudessa" - ai_helper_enabled_features: "Valitse ominaisuudet, jotka otetaan käyttöön tekoälyavustajassa." - post_ai_helper_allowed_groups: "Käyttäjäryhmät, joilla on oikeus käyttää tekoälyavustajan ominaisuuksia viesteissä" - ai_helper_image_caption_model: "Valitse malli, jota käytetään kuvien kuvatekstien luomiseen" - ai_auto_image_caption_allowed_groups: "Näiden ryhmien käyttäjät voivat ottaa automaattisen kuvatekstityksen käyttöön tai poistaa sen käytöstä." - ai_embeddings_selected_model: "Käytä valittua mallia upotusten luomiseen." - ai_embeddings_generate_for_pms: "Luo upotuksia yksityisviesteille." - ai_embeddings_semantic_related_topics_enabled: "Käytä semanttista hakua aiheeseen liittyviä ketjuja varten." - ai_embeddings_semantic_related_topics: "Aiheeseen liittyvien ketjujen osiossa näytettävien ketjujen enimmäismäärä." - ai_embeddings_backfill_batch_size: "15 minuutin välein täydennettävien upotusten määrä." - ai_embeddings_semantic_search_enabled: "Ota koko sivun semanttinen haku käyttöön." - ai_embeddings_semantic_quick_search_enabled: "Ota semanttinen haku käyttöön hakuvalikon ponnahdusikkunassa." - ai_embeddings_semantic_related_include_closed_topics: "Sisällytä suljetut ketjut semanttisiin hakutuloksiin" - ai_embeddings_semantic_search_hyde_model: "Malli, jota käytetään avainsanojen laajentamiseen parempien tulosten saamiseksi semanttisen haun aikana" - ai_embeddings_per_post_enabled: Luo upotukset jokaiselle viestille - ai_summarization_model: "Yhteenvetoon käytettävä malli" - ai_custom_summarization_allowed_groups: "Ryhmät, jotka voivat luoda uusia yhteenvetoja." - ai_pm_summarization_allowed_groups: "Ryhmät voivat luoda ja tarkastella yhteenvetoja yksityisviesteissä." - ai_summary_gists_enabled: "Luo lyhyitä yhteenvetoja ketjujen uusimmista vastauksista automaattisesti" - ai_summary_gists_allowed_groups: "Ryhmät voivat nähdä yhteenvetoja kuumien ketjujen luettelossa." - ai_summary_backfill_maximum_topics_per_hour: "Täydennettävien ketjujen yhteenvetojen määrä tunnissa." - ai_bot_enabled: "Ota tekoälybottimoduuli käyttöön." - ai_bot_enable_chat_warning: "Näytä varoitus, kun yksityisviesti-chat aloitetaan. Voidaan ohittaa muokkaamalla käännösmerkkijonoa: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Kun GPT-botilla on yksityisviestin käyttöoikeus, se vastaa näiden ryhmien jäsenille." - ai_bot_debugging_allowed_groups: "Salli näiden ryhmien nähdä viesteissä virheenkorjauspainike, joka näyttää raa'an tekoälypyynnön ja vastauksen" - ai_bot_public_sharing_allowed_groups: "Salli näiden ryhmien jakaa tekoälyn yksityisviestejä yleisölle ainutlaatuisen julkisesti käytettävissä olevan linkin kautta. Huomaa: jos sivustosi edellyttää kirjautumista, myös jaot edellyttävät kirjautumista." - ai_bot_add_to_header: "Näytä painike yläpalkissa yksityiskeskustelun aloittamiseksi tekoälybotin kanssa" - ai_bot_github_access_token: "GitHubin käyttötunnus käytettäväksi GitHubin tekoälytyökalujen kanssa (vaaditaan haun tukea varten)" - ai_stability_api_key: "stability.ai-APIn API-avain" - ai_stability_engine: "stability.ai-APIssa käytettävä kuvanluontimoduuli" - ai_stability_api_url: "stability.ai-APIn URL" - ai_google_custom_search_api_key: "Google Custom Search APIn API-avain, katso: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "Google Custom Search APIn CX" - reviewables: - reasons: - flagged_by_toxicity: Tekoälylisäosa liputti tämän luokiteltuaan sen toksiseksi. - flagged_by_nsfw: Tekoälylisäosa liputti tämän luokiteltuaan ainakin yhden liitetyistä kuvista NSFW:ksi. - reports: - overall_sentiment: - title: "Yleinen tunne" - description: 'Kaaviossa verrataan positiivisiksi tai negatiivisiksi luokiteltujen viestien määrää. Nämä lasketaan, kun positiiviset tai negatiiviset pisteet ylittävät asetetun kynnysarvon. Tämä tarkoittaa, että neutraaleja viestejä ei näytetä. Myöskään yksityisviestejä ei lasketa. Luokiteltu "cardiffnlp/twitter-roberta-base-sentiment-latest"-mallilla.' - xaxis: "Positiivinen (%)" - yaxis: "Päivämäärä" - emotion_admiration: - title: "\U0001F929 Ihailu" - description: "Viestit, jotka on luokiteltu ihailun tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_amusement: - title: "\U0001F604 Huvittuneisuus" - description: "Viestit, jotka on luokiteltu huvittuneisuuden tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_anger: - title: "\U0001F620 Viha" - description: "Viestit, jotka on luokiteltu vihan tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_annoyance: - title: "\U0001F612 Ärsytys" - description: "Viestit, jotka on luokiteltu ärsytyksen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_approval: - title: "\U0001F44D Hyväksyntä" - description: "Viestit, jotka on luokiteltu hyväksynnän tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_caring: - title: "\U0001F917 Välittäminen" - description: "Viestit, jotka on luokiteltu välittämisen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_confusion: - title: "\U0001F615 Hämmennys" - description: "Viestit, jotka on luokiteltu hämmennyksen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_curiosity: - title: "\U0001F914 Uteliaisuus" - description: "Viestit, jotka on luokiteltu uteliaisuuden tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_desire: - title: "\U0001F60D Halu" - description: "Viestit, jotka on luokiteltu halun tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_disappointment: - title: "\U0001F61E Pettymys" - description: "Viestit, jotka on luokiteltu pettymyksen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_disapproval: - title: "\U0001F44E Paheksunta" - description: "Viestit, jotka on luokiteltu paheksunnan tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_disgust: - title: "\U0001F922 Inho" - description: "Viestit, jotka on luokiteltu inhon tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_embarrassment: - title: "\U0001F633 Häpeä" - description: "Viestit, jotka on luokiteltu häpeän tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_excitement: - title: "\U0001F92A Innostus" - description: "Viestit, jotka on luokiteltu innostuksen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_fear: - title: "\U0001F628 Pelko" - description: "Viestit, jotka on luokiteltu pelon tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_gratitude: - title: "\U0001F64F Kiitollisuus" - description: "Viestit, jotka on luokiteltu kiitollisuuden tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_grief: - title: "\U0001F622 Murheellisuus" - description: "Viestit, jotka on luokiteltu murheellisuuden tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_joy: - title: "\U0001F60A Ilo" - description: "Viestit, jotka on luokiteltu ilon tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_love: - title: '❤️ Rakkaus' - description: "Viestit, jotka on luokiteltu rakkauden tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_nervousness: - title: "\U0001F630 Hermostuneisuus" - description: "Viestit, jotka on luokiteltu hermostuneisuuden tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_neutral: - title: "\U0001F610 Neutraali" - description: "Viestit, jotka on luokiteltu neutraalilla tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_optimism: - title: "\U0001F31F Optimismi" - description: "Viestit, jotka on luokiteltu optimismin tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_pride: - title: "\U0001F981 Ylpeys" - description: "Viestit, jotka on luokiteltu ylpeyden tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_realization: - title: "\U0001F4A1 Oivallus" - description: "Viestit, jotka on luokiteltu oivalluksen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_relief: - title: "\U0001F60C Helpotus" - description: "Viestit, jotka on luokiteltu helpotuksen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_remorse: - title: "\U0001F614 Katumus" - description: "Viestit, jotka on luokiteltu katumuksen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_sadness: - title: "\U0001F62D Suru" - description: "Viestit, jotka on luokiteltu surullisuuden tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - emotion_surprise: - title: "\U0001F632 Yllätys" - description: "Viestit, jotka on luokiteltu yllätyksen tunteella tekoälyllä mallilla \"SamLowe/roberta-base-go_emotions\"." - discourse_ai: - ai_artifact: - view_source: "Näytä lähde" - view_changes: "Näytä muutokset" - unknown_model: "Tuntematon tekoälymalli" - tools: - custom_name: "%{name} (mukautettu)" - presets: - browse_web_jina: - name: "Selaa verkkoa (jina.ai)" - exchange_rate: - name: "Vaihtokurssi" - stock_quote: - name: "Osakekurssi (AlphaVantage)" - image_generation: - name: "Flux-kuvageneraattori (Together.ai)" - empty_tool: - name: "Aloita tyhjästä..." - ai_helper: - errors: - completion_request_failed: "Jokin meni vikaan yritettäessä antaa ehdotuksia. Yritä uudelleen." - prompts: - translate: Käännä kielelle %{language} - generate_titles: Ehdota ketjujen otsikoita - proofread: Oikolue teksti - markdown_table: Luo markdown-taulukko - custom_prompt: "Mukautettu kehote" - explain: "Selitä" - illustrate_post: "Havainnollista viestiä" - replace_dates: "Älykkäät päivämäärät" - painter: - attribution: - stable_diffusion_xl: "Kuvan tarjoaa Stable Diffusion XL" - dall_e_3: "Kuvan tarjoaa DALL-E 3" - image_caption: - attribution: "Tekoälyn laatima kuvateksti" - share_ai: - read_more: "Lue koko transkriptio" - onebox_title: "Tekoälykeskustelu LLM:n %{llm_name} kanssa" - formatted_excerpt: "Tekoälykeskustelu LLM:n %{llm_name} kanssa:\n %{excerpt}" - title: "%{title} – Tekoälykeskustelu – %{site_name}" - errors: - not_allowed: "Sinulla ei ole oikeutta jakaa tätä ketjua" - other_people_in_pm: "Yksityisviestejä muiden ihmisten kanssa ei voi jakaa julkisesti" - other_content_in_pm: "Yksityisviestejä, jotka sisältävät muiden ihmisten viestejä, ei voi jakaa julkisesti" - failed_to_share: "Keskustelun jakaminen epäonnistui" - conversation_deleted: "Keskustelun jaon poistaminen onnistui" - spam_detection: - flag_reason: "Discourse AI liputti roskapostiksi" - silence_reason: "Discourse AI hiljensi käyttäjän automaattisesti" - invalid_error_type: "Virheellinen virhetyyppi annettu" - unexpected: "Tapahtui odottamaton virhe" - bot_user_update_failed: "Roskapostin skannausbottikäyttäjän päivittäminen epäonnistui" - ai_bot: - reply_error: "Näyttää siltä, että järjestelmässä tapahtui odottamaton ongelma, kun se yritti vastata.\n\n[details='Error details']\n%{details}\n[/details]" - default_pm_prefix: "[Nimetön tekoälybotin yksityisviesti]" - personas: - default_llm_required: "Oletus-LLM-malli vaaditaan ennen chatin käyttöönottoa" - cannot_delete_system_persona: "Järjestelmäpersoonia ei voi poistaa, poista se sen sijaan käytöstä" - cannot_edit_system_persona: "Järjestelmäpersoonia voi vain nimetä uudelleen, et saa muokata työkaluja tai järjestelmäkehotetta, sen sijaan voit poistaa ne käytöstä ja tehdä kopion" - github_helper: - name: "GitHub-apulainen" - description: "Tekoälybotti, joka on erikoistunut avustamaan GitHubiin liittyvissä tehtävissä ja kysymyksissä" - general: - name: Foorumin apulainen - description: "Yleiskäyttöinen tekoälybotti, joka pystyy suorittamaan erilaisia tehtäviä" - artist: - name: Taiteilija - description: "Kuvien luomiseen erikoistunut tekoälybotti" - sql_helper: - name: SQL-apuohjelma - description: "Tekoälybotti, joka on erikoistunut SQL-kyselyiden laatimiseen tässä Discourse-esiintymässä" - settings_explorer: - name: Asetusapuri - description: "Tekoälybotti, joka on erikoistunut auttamaan Discoursen sivustoasetuksiin tutustumisessa" - creative: - name: Luova - description: "Tekoälybotti ilman ulkoisia integraatioita, joka on erikoistuneet luoviin tehtäviin" - dall_e3: - name: "DALL-E 3" - description: "Kuvien luomiseen DALL-E 3:lla erikoistunut tekoälybotti" - discourse_helper: - name: "Discourse-apulainen" - description: "Tekoälybotti, joka on erikoistunut auttamaan Discourseen liittyvissä tehtävissä" - web_artifact_creator: - name: "Verkkoartefaktien luontityökalu" - description: "Interaktiivisten verkkoartefaktien luomiseen erikoistunut tekoälybotti" - custom_prompt: - name: "Mukautettu kehote" - smart_dates: - name: "Älykkäät päivämäärät" - topic_not_found: "Yhteenveto ei ole saatavilla, ketjua ei löydy!" - summarizing: "Laaditaan yhteenvetoa ketjusta" - searching: "Haetaan: \"%{query}\"" - tool_options: - researcher: - max_results: - name: "Tulosten enimmäismäärä" - google: - base_query: - name: "Perushakukysely" - description: "Haussa käytettävä peruskysely. Esimerkkejä: \"site:example.com\" sisältää vain tulokset osoitteesta example.com, before:2022-01-01 sisältää vain tulokset vuodelta 2021 ja sitä ennen. Tämä teksti lisätään hakukyselyn alkuun." - read: - read_private: - name: "Lue yksityisesti" - description: "Salli pääsy kaikkiin ketjuihin, joihin käyttäjällä on pääsy (sisältää oletuksena vain julkiset ketjut)" - search: - search_private: - name: "Hae yksityisesti" - description: "Sisällytä hakutuloksiin kaikki ketjut, joihin käyttäjällä on pääsy (vain julkiset ketjut sisällytetään oletuksena)" - max_results: - name: "Tulosten enimmäismäärä" - description: "Hakuun sisällytettävien tulosten enimmäismäärä – jos tämä on tyhjä, oletussääntöjä käytetään ja määrää skaalataan käytettävän mallin mukaan. Korkein arvo on 100." - base_query: - name: "Perushakukysely" - description: "Peruskysely, jota käytetään haussa. Esimerkki: \"#kiireellinen\" lisää hakukyselyn alkuun \"#kiireellinen\" ja sisältää vain ketjut, joissa on kiireellinen alue tai tunniste." - tool_summary: - update_artifact: "Päivitä verkkoartefakti" - create_artifact: "Luo verkkoartefakti" - web_browser: "Selaa verkkoa" - github_search_files: "GitHub-hakutiedostot" - github_search_code: "GitHub-koodihaku" - github_file_content: "GitHub-tiedostosisältö" - github_pull_request_diff: "GitHub-vetopyyntöero" - random_picker: "Satunnainen valitsin" - categories: "Listaa alueet" - search: "Haku" - tags: "Listaa tunnisteet" - time: "Aika" - summarize: "Tee yhteenveto" - image: "Luo kuva" - google: "Hae Googlesta" - read: "Lue ketju" - setting_context: "Etsi sivustoasetuksen konteksti" - schema: "Etsi tietokantaskeema" - search_settings: "Haetaan sivustoasetuksia" - dall_e: "Luo kuva" - search_meta_discourse: "Haku Metasta Discoursessa" - javascript_evaluator: "Arvioi JavaScript" - tool_help: - update_artifact: "Päivitä verkkoartefakti tekoälybotilla" - create_artifact: "Luo verkkoartefakti tekoälybotilla" - web_browser: "Selaa verkkosivua tekoälybotilla" - github_search_code: "Etsi koodia GitHub-tietovarastosta" - github_search_files: "Etsi tiedostoja GitHub-tietovarastosta" - github_file_content: "Nouda tiedostojen sisältöä GitHub-tietovarastosta" - github_pull_request_diff: "Nouda GitHubin vetopyynnön ero" - random_picker: "Valitse satunnainen luku tai satunnainen luettelon elementti" - categories: "Listaa kaikki foorumin julkisesti näkyvät alueet" - search: "Hae kaikista foorumin julkisista ketjuista" - tags: "Listaa kaikki foorumin tunnisteet" - time: "Etsi aika eri aikavyöhykkeillä" - summary: "Laadi yhteenveto ketjusta" - image: "Luo kuva Stable Diffusionilla" - google: "Hae Googlesta kyselyä" - read: "Lue julkinen ketju foorumilla" - setting_context: "Etsi sivustoasetuksen konteksti" - schema: "Etsi tietokantaskeema" - search_settings: "Hae sivustoasetuksia" - dall_e: "Luo kuva DALL-E 3:lla" - search_meta_discourse: "Haku Metasta Discoursessa" - javascript_evaluator: "Arvioi JavaScript" - tool_description: - update_artifact: "Verkkoartefakti päivitettiin tekoälybotilla" - web_browser: "Luetaan: %{url}" - github_search_files: "Haettiin avainsanoilla \"%{keywords}\" tietovarastosta %{repo}/%{branch}" - github_search_code: "Haettiin ehdolla \"%{query}\" tietovarastosta %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Noudettiin tiedostojen %{file_paths} sisältö tietovarastosta %{repo_name}@%{branch}" - random_picker: "Valitaan vaihtoehdoista %{options}, valittu: %{result}" - read: "Luetaan: %{title}" - time: "Aika aikavyöhykkeellä %{timezone} on %{time}" - summarize: "Yhteenveto: %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "Löytyi %{count} alue" - other: "Löytyi %{count} aluetta" - tags: - one: "Löytyi %{count} tunniste" - other: "Löytyi %{count} tunnistetta" - search: - one: "Ehdolla \"%{query}\" löytyi %{count} tulos" - other: "Ehdolla \"%{query}\" löytyi %{count} tulosta" - search_meta_discourse: - one: "Ehdolla \"%{query}\" löytyi %{count} tulos" - other: "Ehdolla \"%{query}\" löytyi %{count} tulosta" - google: - one: "Ehdolla \"%{query}\" löytyi %{count} tulos" - other: "Ehdolla \"%{query}\" löytyi %{count} tulosta" - setting_context: "Luetaan konteksti asetukselle: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "Ehdolla \"%{query}\" löytyi %{count} tulos" - other: "Ehdolla \"%{query}\" löytyi %{count} tulosta" - summarization: - configuration_hint: - one: "Määritä ensin %{setting}-asetus." - other: "Määritä ensin nämä asetukset: %{settings}" - chat: - no_targets: "Valitun ajanjakson aikana ei ollut viestejä." - sentiment: - reports: - overall_sentiment: "Yleinen tunne (positiivinen–negatiivinen)" - post_emotion: - sadness: "Suru \U0001F622" - surprise: "Yllätys \U0001F631" - neutral: "Neutraali \U0001F610" - fear: "Pelko \U0001F628" - anger: "Viha \U0001F621" - joy: "Ilo \U0001F600" - disgust: "Inho \U0001F922" - sentiment_analysis: - positive: "Positiivinen" - negative: "Negatiivinen" - neutral: "Neutraali" - llm: - configuration: - disable_module_first: "Sinun täytyy ensin poistaa %{setting} käytöstä." - set_llm_first: "Aseta %{setting} ensin" - model_unreachable: "Emme saaneet vastausta tästä mallista. Tarkista ensin asetuksesi." - invalid_seeded_model: "Et voi käyttää tätä mallia tämän ominaisuuden kanssa" - must_select_model: "Sinun täytyy valita suuri kielimalli ensin" - endpoints: - not_configured: "%{display_name} (ei määritetty)" - configuration_hint: - one: "Varmista, että asetus \"%{settings}\" on määritetty." - other: "Varmista, että nämä asetukset on määritetty: %{settings}" - delete_failed: - one: "Tätä mallia ei voitu poistaa, koska %{settings} käyttää sitä. Päivitä asetus ja yritä uudelleen." - other: "Tätä mallia ei voitu poistaa, koska %{settings} käyttävät sitä. Päivitä asetus ja yritä uudelleen." - cannot_edit_builtin: "Et voi muokata sisäänrakennettua mallia." - embeddings: - delete_failed: "Tämä malli on tällä hetkellä käytössä. Päivitä \"ai embeddings selected model\" ensin." - cannot_edit_builtin: "Et voi muokata sisäänrakennettua mallia." - configuration: - disable_embeddings: "Sinun täytyy ensin poistaa \"ai embeddings enabled\" käytöstä." - choose_model: "Aseta \"ai embeddings selected model\" ensin." - llm_models: - missing_provider_param: "%{param} ei voi olla tyhjä" - bedrock_invalid_url: "Täytä kaikki kentät, jotta voit käyttää tätä mallia." - ai_staff_action_logger: - updated: "päivitetty" - removed: "poistettu" - errors: - quota_exceeded: "Olet ylittänyt tämän mallin kiintiön. Odota %{relative_time} ja yritä sitten uudelleen." - quota_required: "Sinun on määritettävä tälle mallille saneiden tai käyttökertojen enimmäismäärä" - no_query_specified: Kyselyparametri on pakollinen, määritä se. - no_user_for_persona: Määritetyllä persoonalle ei ole siihen liitettyä käyttäjää. - persona_not_found: Määritettyä persoonaa ei ole olemassa. Tarkista parametrit persona_name tai persona_id. - no_user_specified: Parametri username tai user_unique_id vaaditaan, määritä se. - user_not_found: Määritettyä käyttäjää ei ole olemassa. Tarkista username-parametri. - persona_disabled: Määritetty persoona ei ole käytössä. Tarkista parametrit persona_name tai persona_id. - no_default_llm: Persoonalla täytyy olla default_llm määritelty. - user_not_allowed: Käyttäjä ei saa osallistua ketjuun. - prompt_message_length: Viesti %{idx} ylittää 1 000 merkin rajan. diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml deleted file mode 100644 index 650a2465..00000000 --- a/config/locales/server.fr.yml +++ /dev/null @@ -1,445 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -fr: - discourse_automation: - ai: - flag_types: - review: "Ajouter une publication à la file d'attente de révision" - spam: "Signaler comme spam et masquer la publication" - spam_silence: "Signaler comme spam, masquer la publication et désactiver l'utilisateur" - scriptables: - llm_triage: - title: Trier les publications à l'aide de l'IA - description: "Triez les publications à l'aide d'un grand modèle linguistique" - flagged_post: | -
Réponse du modèle :
-

%%LLM_RESPONSE%%

- Déclenché par la règle %%AUTOMATION_NAME%%. - llm_report: - title: Rapport périodique utilisant l'IA - description: "Rapport périodique basé sur un grand modèle linguistique" - site_settings: - discourse_ai_enabled: "Activez l'extension IA de Discourse." - ai_toxicity_enabled: "Activez le module de toxicité." - ai_toxicity_inference_service_api_endpoint: "Adresse URL où s'exécute l'API pour le module de toxicité" - ai_toxicity_inference_service_api_key: "Clé API pour l'API de toxicité" - ai_toxicity_inference_service_api_model: "Modèle à utiliser pour l'inférence. Le modèle multilingue fonctionne avec l'italien, le français, le russe, le portugais, l'espagnol et le turc." - ai_toxicity_flag_automatically: "Signalez automatiquement les publications et les messages de conversation qui dépassent les seuils configurés." - ai_toxicity_flag_threshold_toxicity: "Toxicité : commentaire grossier, irrespectueux ou déraisonnable susceptible de vous inciter à quitter une discussion ou à renoncer à partager votre point de vue" - ai_toxicity_flag_threshold_severe_toxicity: "Toxicité grave : commentaire très haineux, agressif ou irrespectueux susceptible de vous inciter à quitter une discussion ou à renoncer à partager votre point de vue" - ai_toxicity_flag_threshold_obscene: "Obscène" - ai_toxicity_flag_threshold_identity_attack: "Attaque d'identité" - ai_toxicity_flag_threshold_insult: "Insulte" - ai_toxicity_flag_threshold_threat: "Menace" - ai_toxicity_flag_threshold_sexual_explicit: "Sexualité explicite" - ai_toxicity_groups_bypass: "Les utilisateurs de ces groupes ne verront pas leurs messages classés par le module de toxicité." - ai_sentiment_enabled: "Activez le module de sentiment." - ai_sentiment_inference_service_api_endpoint: "Adresse URL où s'exécute l'API pour le module de sentiment" - ai_sentiment_inference_service_api_key: "Clé API pour l'API de sentiment" - ai_sentiment_models: "Modèles à utiliser pour l'inférence. Le sentiment classe les publications dans la plage positif/neutre/négatif. Les émotions se classent dans la plage colère/dégoût/peur/joie/neutre/tristesse/surprise." - ai_nsfw_detection_enabled: "Activez le module NSFW." - ai_nsfw_inference_service_api_endpoint: "Adresse URL où l'API s'exécute pour le module NSFW" - ai_nsfw_inference_service_api_key: "Clé API pour l'API NSFW" - ai_nsfw_flag_automatically: "Signalez automatiquement les publications NSFW qui dépassent les seuils configurés." - ai_nsfw_flag_threshold_general: "Seuil général pour qu'une image soit considérée comme NSFW." - ai_nsfw_flag_threshold_drawings: "Seuil pour qu'un dessin soit considéré comme NSFW." - ai_nsfw_flag_threshold_hentai: "Seuil pour qu'une image classée comme hentai soit considérée comme NSFW." - ai_nsfw_flag_threshold_porn: "Seuil pour qu'une image classée comme pornographique soit considérée comme NSFW." - ai_nsfw_flag_threshold_sexy: "Seuil pour qu'une image classée comme sexy soit considérée comme NSFW." - ai_nsfw_models: "Modèles à utiliser pour l'inférence NSFW." - ai_helper_enabled: "Activer l'assistant IA." - composer_ai_helper_allowed_groups: "Les utilisateurs de ces groupes verront le bouton d'assistance IA dans le compositeur." - ai_helper_allowed_in_pm: "Activez l'assistant IA du compositeur dans les messages privés." - ai_helper_model: "Modèle à utiliser pour l'assistant IA." - ai_helper_custom_prompts_allowed_groups: "Les utilisateurs de ces groupes verront l'option d'invite personnalisée dans l'assistant IA." - ai_helper_automatic_chat_thread_title_delay: "Délai en minutes avant que l'assistant IA définisse automatiquement le titre du fil de discussion." - ai_helper_automatic_chat_thread_title: "Définissez automatiquement les titres des fils de discussion en fonction du contenu du fil." - ai_helper_illustrate_post_model: "Modèle à utiliser pour la fonction d'illustration de publication de l'assistant IA du compositeur" - ai_helper_enabled_features: "Sélectionnez les fonctionnalités à activer dans l’assistant IA." - post_ai_helper_allowed_groups: "Les groupes d'utilisateurs autorisés à accéder aux fonctionnalités d'AI Helper dans les publications" - ai_helper_image_caption_model: "Sélectionnez le modèle à utiliser pour générer des légendes d'images" - ai_auto_image_caption_allowed_groups: "Les utilisateurs de ces groupes peuvent activer ou désactiver les légendes automatiques des images." - ai_embeddings_selected_model: "Utilisez le modèle sélectionné pour générer des intégrations." - ai_embeddings_generate_for_pms: "Générez des intégrations pour les messages privés." - ai_embeddings_semantic_related_topics_enabled: "Utilisez la recherche sémantique pour les sujets connexes." - ai_embeddings_semantic_related_topics: "Nombre maximal de sujets à afficher dans la section des sujets connexes." - ai_embeddings_backfill_batch_size: "Nombre d'intégrations à remplir toutes les 15 minutes." - ai_embeddings_semantic_search_enabled: "Activez la recherche sémantique en pleine page." - ai_embeddings_semantic_quick_search_enabled: "Activez l'option de recherche sémantique dans la fenêtre contextuelle du menu de recherche." - ai_embeddings_semantic_related_include_closed_topics: "Inclure des sujets fermés dans les résultats de recherche sémantique" - ai_embeddings_semantic_search_hyde_model: "Modèle utilisé pour développer des mots-clés afin d'obtenir de meilleurs résultats lors d'une recherche sémantique" - ai_embeddings_per_post_enabled: Générer des intégrations pour chaque publication - ai_summarization_model: "Modèle à utiliser pour les résumés" - ai_custom_summarization_allowed_groups: "Les groupes autorisés à utiliser la création de nouveaux résumés." - ai_pm_summarization_allowed_groups: "Groupes autorisés à créer et à afficher des résumés dans les MD." - ai_summary_gists_enabled: "Générez automatiquement de brefs résumés des dernières réponses dans les sujets" - ai_summary_gists_allowed_groups: "Les groupes sont autorisés à voir l'essentiel dans la liste des sujets d'actualité." - ai_summary_backfill_maximum_topics_per_hour: "Nombre de résumés de sujets à compléter par heure." - ai_bot_enabled: "Activez le module du robot IA." - ai_bot_enable_chat_warning: "Afficher un avertissement lorsque le chat MP est lancé. Peut être remplacé en modifiant la chaîne de traduction : discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Lorsque le robot GPT aura accès aux messages privés, il répondra aux membres de ces groupes." - ai_bot_debugging_allowed_groups: "Autoriser ces groupes à voir un bouton de débogage sur les publications qui affiche la demande et la réponse brutes de l'IA" - ai_bot_public_sharing_allowed_groups: "Autorisez ces groupes à partager des messages personnels de l'IA avec le public via un lien unique accessible au public. Remarque : si votre site nécessite une connexion, les partages nécessiteront également une connexion." - ai_bot_add_to_header: "Afficher un bouton dans l'en-tête pour démarrer une conversation avec un robot IA" - ai_bot_github_access_token: "Jeton d'accès GitHub à utiliser avec les outils GitHub AI (requis pour la prise en charge de la recherche)" - ai_stability_api_key: "Clé API pour l'API stable.ai" - ai_stability_engine: "Moteur de génération d'images à utiliser pour l'API stability.ai" - ai_stability_api_url: "Adresse URL de l'API stability.ai" - ai_google_custom_search_api_key: "Clé API pour l'API Google Custom Search, voir : https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX pour l'API de recherche personnalisée Google" - reviewables: - reasons: - flagged_by_toxicity: L'extension IA l'a signalé après l'avoir classé comme toxique. - flagged_by_nsfw: L'extension IA l'a signalé après avoir classé au moins une des images jointes comme NSFW. - reports: - overall_sentiment: - title: "Sentiment général" - description: 'Le graphique compare les nombres de publications classées comme positives ou négatives. Ces nombres sont calculés lorsque les scores positifs ou négatifs dépassent le score seuil défini. Cela signifie que les publications neutres ne sont pas affichées. Les messages directs (MD) sont également exclus. Classé dans « cardiffnlp/twitter-roberta-base-sentiment-latest »' - xaxis: "Positif (%)" - yaxis: "Date" - emotion_admiration: - title: "\U0001F929 Admiration" - description: "Publications classées selon l'émotion d'admiration via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_amusement: - title: "\U0001F604 Amusement" - description: "Publications classées selon l'émotion d'amusement via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_anger: - title: "\U0001F620 Colère" - description: "Publications classées selon l'émotion de colère via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_annoyance: - title: "\U0001F612 Agacement" - description: "Publications classées selon l'émotion d'agacement via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_approval: - title: "\U0001F44D Approbation" - description: "Publications classées selon l'émotion d'approbation via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_caring: - title: "\U0001F917 Bienveillance" - description: "Publications classées selon l'émotion de bienveillance via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_confusion: - title: "\U0001F615 Confusion" - description: "Publications classées selon l'émotion de confusion via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_curiosity: - title: "\U0001F914 Curiosité" - description: "Publications classées selon l'émotion de curiosité via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_desire: - title: "\U0001F60D Désir" - description: "Publications classées selon l'émotion de désir via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_disappointment: - title: "\U0001F61E Déception" - description: "Publications classées selon l'émotion de déception via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_disapproval: - title: "\U0001F44E Désapprobation" - description: "Publications classées selon l'émotion de désapprobation via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_disgust: - title: "\U0001F922 Dégoût" - description: "Publications classées selon l'émotion de dégoût via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_embarrassment: - title: "\U0001F633 Embarras" - description: "Publications classées selon l'émotion d'embarras via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_excitement: - title: "\U0001F92A Excitation" - description: "Publications classées selon l'émotion d'excitation via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_fear: - title: "\U0001F628 Peur" - description: "Publications classées selon l'émotion de peur via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_gratitude: - title: "\U0001F64F Gratitude" - description: "Publications classées selon l'émotion de gratitude via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_grief: - title: "\U0001F622 Chagrin" - description: "Publications classées selon l'émotion de chagrin via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_joy: - title: "\U0001F60A Joie" - description: "Publications classées selon l'émotion de joie via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_love: - title: '❤️ Amour' - description: "Publications classées selon l'émotion d'amour via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_nervousness: - title: "\U0001F630 Nervosité" - description: "Publications classées selon l'émotion de nervosité via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_neutral: - title: "\U0001F610 Neutre" - description: "Publications classées selon l'émotion de neutralité via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_optimism: - title: "\U0001F31F Optimisme" - description: "Publications classées selon l'émotion d'optimisme via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_pride: - title: "\U0001F981 Fierté" - description: "Publications classées selon l'émotion de fierté via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_realization: - title: "\U0001F4A1 Réalisation" - description: "Publications classées selon l'émotion de réalisation via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_relief: - title: "\U0001F60C Soulagement" - description: "Publications classées selon l'émotion de soulagement via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_remorse: - title: "\U0001F614 Remords" - description: "Publications classées selon l'émotion de remords via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_sadness: - title: "\U0001F62D Tristesse" - description: "Publications classées selon l'émotion de tristesse via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - emotion_surprise: - title: "\U0001F632 Surprise" - description: "Publications classées selon l'émotion de surprise via l'IA, en utilisant le modèle « Samlowe/Roberta-Base-Go_Emotions »." - discourse_ai: - ai_artifact: - view_source: "Voir la source" - view_changes: "Afficher les modifications" - unknown_model: "Modèle d'IA inconnu" - tools: - custom_name: "%{name} (personnalisé)" - presets: - browse_web_jina: - name: "Parcourir le Web (jina.ai)" - exchange_rate: - name: "Taux de change" - stock_quote: - name: "Cotation boursière (AlphaVantage)" - image_generation: - name: "Générateur d'images de flux (Together.ai)" - empty_tool: - name: "Repartir de zéro..." - ai_helper: - errors: - completion_request_failed: "Une erreur s'est produite lors de la tentative de fournir des suggestions. Veuillez réessayer." - prompts: - translate: Traduire en %{language} - generate_titles: Suggérer des titres de sujets - proofread: Relisez le texte - markdown_table: Générer un tableau Markdown - custom_prompt: "Invite personnalisée" - explain: "Expliquer" - illustrate_post: "Illustrer la publication" - replace_dates: "Dates intelligentes" - painter: - attribution: - stable_diffusion_xl: "Image de Stable Diffusion XL" - dall_e_3: "Image de DALL-E 3" - image_caption: - attribution: "Légende par l'IA" - share_ai: - read_more: "Lire la transcription complète" - onebox_title: "Conversation IA avec %{llm_name}" - formatted_excerpt: "Conversation IA avec %{llm_name} :\n %{excerpt}" - title: "%{title} - Conversation IA - %{site_name}" - errors: - not_allowed: "Vous n'avez pas l'autorisation de partager ce sujet" - other_people_in_pm: "Les messages personnels avec d'autres humains ne peuvent pas être partagés publiquement" - other_content_in_pm: "Les messages personnels contenant des messages d'autres personnes ne peuvent pas être partagés publiquement" - failed_to_share: "Échec du partage de la conversation" - conversation_deleted: "Partage de conversation supprimé avec succès" - spam_detection: - flag_reason: "Signalé comme spam par Discourse AI" - silence_reason: "Utilisateur mis en sourdine automatiquement par Discourse AI" - invalid_error_type: "Le type d'erreur fourni n'est pas valide" - unexpected: "Une erreur inattendue s'est produite" - bot_user_update_failed: "Échec de la mise à jour de l'utilisateur du robot d'analyse anti-spam" - ai_bot: - reply_error: "Il semble que notre système ait rencontré un problème inattendu en essayant de répondre.\n\n[details='Détails de l'erreur']\n%{details}\n[/details]" - default_pm_prefix: "[MD de robot IA sans titre]" - personas: - default_llm_required: "Le modèle LLM par défaut est requis avant d'activer Chat" - cannot_delete_system_persona: "Les personnages système ne peuvent pas être supprimés, veuillez plutôt les désactiver" - cannot_edit_system_persona: "Les personnages du système peuvent uniquement être renommés, vous ne pouvez pas modifier les outils ou l'invite du système, mais seulement les désactiver et en faire une copie" - github_helper: - name: "Assistant GitHub" - description: "Robot IA spécialisé dans l'assistance aux tâches et questions liées à GitHub" - general: - name: Assistant du forum - description: "Bot IA à usage général capable d'effectuer diverses tâches" - artist: - name: Artiste - description: "Bot IA spécialisé dans la génération d'images" - sql_helper: - name: Assistant SQL - description: "Bot IA spécialisé dans l'aide à la création de requêtes SQL sur cette instance Discourse" - settings_explorer: - name: Explorateur de paramètres - description: "Bot IA spécialisé dans l'aide à l'exploration des paramètres du site Discourse" - creative: - name: Créatif - description: "Bot IA sans intégration externe spécialisé dans les tâches créatives" - dall_e3: - name: "DALL-E 3" - description: "Bot IA spécialisé dans la génération d'images utilisant DALL-E 3" - discourse_helper: - name: "Assistant Discourse" - description: "Robot IA spécialisé dans l'aide aux tâches liées à Discourse" - web_artifact_creator: - name: "Créateur d'artefacts Web" - description: "Robot IA spécialisé dans la création d'artefacts Web interactifs" - custom_prompt: - name: "Invite personnalisée" - smart_dates: - name: "Dates intelligentes" - topic_not_found: "Résumé indisponible, sujet introuvable !" - summarizing: "Synthèse du sujet" - searching: "Recherche de : '%{query}'" - tool_options: - researcher: - max_results: - name: "Nombre maximal de résultats" - google: - base_query: - name: "Requête de recherche de base" - description: "Requête de base à utiliser lors de la recherche. Exemples : « site:exemple.com » n'inclura que les résultats d'exemple.com, « before:2022-01-01 » n'inclura que les résultats de 2021 et avant. Ce texte est ajouté au début de la requête de recherche." - read: - read_private: - name: "Lire en privé" - description: "Autoriser l'accès à tous les sujets auxquels l'utilisateur a accès (par défaut, seuls les sujets publics sont inclus)" - search: - search_private: - name: "Recherche privée" - description: "Inclure tous les sujets auxquels l'utilisateur a accès dans les résultats de recherche (par défaut, seuls les sujets publics sont inclus)" - max_results: - name: "Nombre maximal de résultats" - description: "Nombre maximal de résultats à inclure dans la recherche : si les règles par défaut sont vides, elles seront utilisées et le nombre sera mis à l'échelle en fonction du modèle utilisé. La valeur la plus élevée est 100." - base_query: - name: "Requête de recherche de base" - description: "Requête de base à utiliser lors de la recherche. Exemple : « #urgent » ajoutera « #urgent » à la requête de recherche et inclura uniquement les sujets avec la catégorie ou l'étiquette correspondante." - tool_summary: - update_artifact: "Mettre à jour un artefact Web" - create_artifact: "Créer un artefact Web" - web_browser: "Parcourir le Web" - github_search_files: "Fichiers de recherche GitHub" - github_search_code: "Recherche de code GitHub" - github_file_content: "Contenu du fichier GitHub" - github_pull_request_diff: "Diff des pull requests GitHub" - random_picker: "Sélecteur aléatoire" - categories: "Répertorier les catégories" - search: "Rechercher" - tags: "Répertorier les étiquettes" - time: "Heure" - summarize: "Résumer" - image: "Générer une image" - google: "Rechercher sur Google" - read: "Lire le sujet" - setting_context: "Rechercher le contexte de configuration du site" - schema: "Rechercher le schéma de la base de données" - search_settings: "Recherche des paramètres du site" - dall_e: "Générer une image" - search_meta_discourse: "Méta-recherche Discourse" - javascript_evaluator: "Évaluer JavaScript" - tool_help: - update_artifact: "Mettre à jour un artefact Web à l'aide du robot IA" - create_artifact: "Créer un artefact Web à l'aide du robot IA" - web_browser: "Parcourir la page Web à l'aide du robot IA" - github_search_code: "Rechercher du code dans un dépôt GitHub" - github_search_files: "Rechercher des fichiers dans un dépôt GitHub" - github_file_content: "Récupérer le contenu des fichiers depuis un dépôt GitHub" - github_pull_request_diff: "Récupérer un diff de pull request GitHub" - random_picker: "Choisissez un nombre aléatoire ou un élément aléatoire d'une liste" - categories: "Répertorier toutes les catégories visibles publiquement sur le forum" - search: "Rechercher dans tous les sujets publics sur le forum" - tags: "Répertorier toutes les étiquettes du forum" - time: "Trouver l'heure dans différents fuseaux horaires" - summary: "Résumer un sujet" - image: "Générer une image à l'aide de Stable Diffusion" - google: "Rechercher une requête sur Google" - read: "Lire le sujet public sur le forum" - setting_context: "Rechercher le contexte de configuration du site" - schema: "Rechercher le schéma de la base de données" - search_settings: "Paramètres du site de recherche" - dall_e: "Générer une image à l'aide de DALL-E 3" - search_meta_discourse: "Méta-recherche Discourse" - javascript_evaluator: "Évaluer JavaScript" - tool_description: - update_artifact: "Mise à jour d'un artefact Web à l'aide du robot IA" - web_browser: "Lecture : %{url}" - github_search_files: "Recherche de « %{keywords} » dans %{repo}/%{branch}" - github_search_code: "Recherche de « %{query} » dans %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Contenu récupéré de %{file_paths} de %{repo_name}@%{branch}" - random_picker: "Choix parmi %{options}, sélection : %{result}" - read: "Lecture : %{title}" - time: "L'heure (%{timezone}) est %{time}" - summarize: "Résumé de %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "%{count} catégorie trouvée" - other: "%{count} catégories trouvées" - tags: - one: "%{count} étiquette trouvée" - other: "%{count} étiquettes trouvées" - search: - one: "%{count} résultat trouvé pour « %{query} »" - other: "%{count} résultats trouvés pour « %{query} »" - search_meta_discourse: - one: "%{count} résultat trouvé pour « %{query} »" - other: "%{count} résultats trouvés pour « %{query} »" - google: - one: "%{count} résultat trouvé pour « %{query} »" - other: "%{count} résultats trouvés pour « %{query} »" - setting_context: "Contexte de lecture pour : %{setting_name}" - schema: "%{tables}" - search_settings: - one: "%{count} résultat trouvé pour « %{query} »" - other: "%{count} résultats trouvés pour « %{query} »" - summarization: - configuration_hint: - one: "Configurez d'abord le paramètre « %{setting} »." - other: "Configurez d'abord ces paramètres : %{settings}" - chat: - no_targets: "Il n'y a eu aucun message pendant la période sélectionnée." - sentiment: - reports: - overall_sentiment: "Sentiment général (positif - négatif)" - post_emotion: - sadness: "Tristesse \U0001F622" - surprise: "Surprise \U0001F631" - neutral: "Neutre \U0001F610" - fear: "Peur \U0001F628" - anger: "Colère \U0001F621" - joy: "Joie \U0001F600" - disgust: "Dégoût \U0001F922" - sentiment_analysis: - positive: "Positif" - negative: "Négatif" - neutral: "Neutre" - llm: - configuration: - disable_module_first: "Vous devez d'abord désactiver %{setting}." - set_llm_first: "Réglez %{setting} en premier" - model_unreachable: "Nous n'avons pas pu obtenir de réponse de ce modèle. Vérifiez d'abord vos paramètres." - invalid_seeded_model: "Vous ne pouvez pas utiliser ce modèle avec cette fonctionnalité" - must_select_model: "Vous devez d'abord sélectionner un LLM" - endpoints: - not_configured: "%{display_name} (non configuré)" - configuration_hint: - one: "Assurez-vous que le paramètre « %{settings} » a été configuré." - other: "Assurez-vous que ces paramètres ont été configurés : %{settings}" - delete_failed: - one: "Nous n'avons pas pu supprimer ce modèle, car %{settings} l'utilise. Mettez à jour le paramètre et réessayez." - other: "Nous n'avons pas pu supprimer ce modèle, car %{settings} l'utilisent. Mettez à jour les paramètres et réessayez." - cannot_edit_builtin: "Vous ne pouvez pas modifier un modèle intégré." - embeddings: - delete_failed: "Ce modèle est actuellement utilisé. Mettez d'abord à jour `ai embeddings selected model`." - cannot_edit_builtin: "Vous ne pouvez pas modifier un modèle intégré." - configuration: - disable_embeddings: "Vous devez d'abord désactiver « l'intégration de l'IA activée »." - choose_model: "Définissez d'abord 'ai embeddings selected model'." - llm_models: - missing_provider_param: "%{param} ne peut pas être vide" - bedrock_invalid_url: "Veuillez remplir tous les champs pour utiliser ce modèle." - ai_staff_action_logger: - updated: "mis à jour" - removed: "supprimé" - errors: - quota_exceeded: "Vous avez dépassé le quota pour ce modèle. Veuillez réessayer dans %{relative_time}." - quota_required: "Vous devez spécifier un nombre maximal de jetons ou d'utilisations pour ce modèle" - no_query_specified: Le paramètre de requête est obligatoire, veuillez le spécifier. - no_user_for_persona: Le personnage spécifié n'a pas d'utilisateur associé. - persona_not_found: Le personnage spécifié n'existe pas. Vérifiez les paramètres persona_name ou persona_id. - no_user_specified: Le nom d'utilisateur ou le paramètre user_unique_id est obligatoire. Veuillez le préciser. - user_not_found: L'utilisateur spécifié n'existe pas. Vérifiez le paramètre du nom d'utilisateur. - persona_disabled: Le personnage spécifié est désactivé. Vérifiez les paramètres persona_name ou persona_id. - no_default_llm: Le personnage doit avoir un default_llm défini. - user_not_allowed: L'utilisateur n'est pas autorisé à participer au sujet. - prompt_message_length: Le message %{idx} dépasse la limite de 1 000 caractères. diff --git a/config/locales/server.gl.yml b/config/locales/server.gl.yml deleted file mode 100644 index 0dfbbd28..00000000 --- a/config/locales/server.gl.yml +++ /dev/null @@ -1,26 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -gl: - reports: - overall_sentiment: - yaxis: "Data" - emotion_neutral: - title: "\U0001F610 Neutro" - discourse_ai: - ai_bot: - tool_summary: - search: "Buscar" - time: "Hora" - sentiment: - reports: - post_emotion: - neutral: "Neutro \U0001F610" - sentiment_analysis: - neutral: "Neutro" - ai_staff_action_logger: - updated: "actualizado" - removed: "retirado" diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml deleted file mode 100644 index 2253bf88..00000000 --- a/config/locales/server.he.yml +++ /dev/null @@ -1,527 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -he: - discourse_automation: - ai: - flag_types: - review: "הוספת פוסט לתור הביקורת" - spam: "סימון כזבל והסתרת הפוסט" - spam_silence: "סימון כזבל, הסתרת הפוסט והשתקת המשתמש" - scriptables: - llm_tool_triage: - title: מיון ראשוני של פוסטים באמצעות כלי בינה מלאכותית - llm_triage: - title: מיון ראשוני של פוסטים באמצעות בינה מלאכותית - description: "מיון ראשוני של פוסטים באמצעות דגם שפה גדול" - flagged_post: | -
תגובה מהמודל:
-

%%LLM_RESPONSE%%

- הוזנקה על ידי הכלל %%AUTOMATION_NAME%%. - llm_report: - title: דוח תקופתי בעזרת בינה מלאכותית - description: "דוח תקופתי שמבוסס על דגם שפה גדול" - site_settings: - discourse_ai_enabled: "הפעלת תוסף הבינה המלאכותית של Discourse." - ai_artifact_security: "מערכת התוצרים של הבינה המלאכותית מייצרת חלוניות (IFRAME) עם קוד שניתן להריץ. מצב נוקשה מאלץ לחיצה נוספת כדי להריץ קוד. מצב Lax מריץ קוד ישירות. מצב משולב מאפשר למשתמש לספק data-ai-artifact-autorun שיופיע מיידית. מצב מושבת משבית את מערכת התוצרים." - ai_toxicity_enabled: "הפעלת מודול הרעילות." - ai_toxicity_inference_service_api_endpoint: "הכתובת בה נמצא ה־API הפעיל עבור מודול הרעילות" - ai_toxicity_inference_service_api_key: "מפתח API ל־API של הרעילות" - ai_toxicity_inference_service_api_model: "דגם לשימוש להסקת מסקנות. הדגם הרב־לשוני עובד עם איטלקית, צרפתית, רוסית, פורטוגלית, ספרדית וטורקית." - ai_toxicity_flag_automatically: "לסמן פוסטים / הודעות צ׳אט שמעל הספים המוגדרים אוטומטית." - ai_toxicity_flag_threshold_toxicity: "רעילות: הערה גסה, מזלזלת או בלתי הגיונית שעלולה לגרום לך לעזוב דיון או לוותר על שיתוף העמדה שלך" - ai_toxicity_flag_threshold_severe_toxicity: "רעילות חמורה: הערה שטופת שנאה, תוקפנית או מזלזלת במיוחד שבהחלט עלולה לגרום לך לעזוב דיון או לוותר על שיתוף העמדה שלך" - ai_toxicity_flag_threshold_obscene: "מגונה" - ai_toxicity_flag_threshold_identity_attack: "מתקפת זהות" - ai_toxicity_flag_threshold_insult: "עלבון" - ai_toxicity_flag_threshold_threat: "איום" - ai_toxicity_flag_threshold_sexual_explicit: "מיניות מפורשת" - ai_toxicity_groups_bypass: "הפוסטים של משתמשים בקבוצות האלו לא יסווגו על ידי מודול הרעילות." - ai_sentiment_enabled: "הפעלת מודול התחושה." - ai_sentiment_inference_service_api_endpoint: "הכתובת בה נמצא ה־API הפעיל עבור מודול התחושה" - ai_sentiment_inference_service_api_key: "מפתח API ל־API של התחושה" - ai_sentiment_models: "מודלים לטובת הסקת מסקנות. תחושה מסווגת את הפוסט על המנעד של חיובי/נייטרלי/שלילי. רגש מסווג על המנעד של כעס/גועל/פחד/שמחה/נייטרליות/עצב/הפתעה." - ai_nsfw_detection_enabled: "הפעלת מודול הפוגענות." - ai_nsfw_inference_service_api_endpoint: "הכתובת בה נמצא ה־API הפעיל עבור מודול הפוגענות" - ai_nsfw_inference_service_api_key: "מפתח API ל־API לתכנים פוגעניים" - ai_nsfw_flag_automatically: "לסמן פוסטים פוגעניים שמעל הספים המוגדרים אוטומטית." - ai_nsfw_flag_threshold_general: "סף כללי כדי שתמונה תיחשב לפוגענית." - ai_nsfw_flag_threshold_drawings: "סף לציור כדי שייחשב לפוגעני." - ai_nsfw_flag_threshold_hentai: "סף לתמונה שמוגדרת כהנטאי כדי שתיחשב פוגענית." - ai_nsfw_flag_threshold_porn: "סף לתמונה שמוגדרת כארוטית כדי שתיחשב פוגענית." - ai_nsfw_flag_threshold_sexy: "סף לתמונה שמוגדרת כמינית כדי שתיחשב פוגענית." - ai_nsfw_models: "מודלים לשימוש להסקת פוגענות." - ai_openai_api_key: "מפתח API ל־API של OpenAI. משמש רק ליצירת ועריכת תמונות. בשביל GPT יש להשתמש בלשונית הגדרת ה־LLM." - ai_openai_image_generation_url: "כתובת ל־API ליצירת תמונות מבית OpenAI" - ai_openai_image_edit_url: "כתובת ל־API לעריכת תמונות מבית OpenAI" - ai_helper_enabled: "הפעלת מסייע הבינה המלאכותית." - composer_ai_helper_allowed_groups: "משתמשים בקבוצות אלו יראו כפתור מסייע בינה מלאכותית במחבר ההודעות." - ai_helper_allowed_in_pm: "הפעלת מסייע הבינה המלאכותית בכותב ההודעות בהודעות הפרטיות." - ai_helper_model: "דגם לשימוש למסייע הבינה המלאכותית." - ai_helper_custom_prompts_allowed_groups: "משתמשים בקבוצות אלה יראו את אפשרות הבקשה המותאמת אישית במסייע הבינה המלאכותית." - ai_helper_automatic_chat_thread_title_delay: "השהייה בשניות לפני שמסייע הבינה המלאכותית מגדיר את כותרת שרשור הצ׳אט אוטומטית." - ai_helper_automatic_chat_thread_title: "הגדרת כותרות שרשור הצ׳אט אוטומטית לפי תוכן השרשור." - ai_helper_illustrate_post_model: "דגם לשימוש ליכולת הדמיית הפוסט של מסייע הבינה המלאכותית בכתיבה" - ai_helper_enabled_features: "נא לבחור יכולות להפעלה במסייע הבינה המלאכותית." - post_ai_helper_allowed_groups: "קבוצות משתמשים שמורשות לגשת ליכולות מסייע בינה מלאכותית בפוסטים" - ai_helper_image_caption_model: "נא לבחור את הדגם שישמש ליצירת כותרות לתמונות" - ai_auto_image_caption_allowed_groups: "משתמשים בקבוצות האלה יכולים להפעיל או לכבות כותרות אוטומטיות לתמונות." - ai_embeddings_generate_for_pms: "יצירת הטמעות להודעות פרטיות." - ai_embeddings_semantic_related_topics_enabled: "להשתמש בחיפוש סמנטי לנושאים קשורים." - ai_embeddings_semantic_related_topics: "מספר הנושאים המרבי להצגה בסעיף הנושאים הקשורים." - ai_embeddings_backfill_batch_size: "מספר ההטבעות למילוי חוזר כל 15 דקות." - ai_embeddings_semantic_search_enabled: "הפעלת חיפוש סמנטי בעמוד מלא." - ai_embeddings_semantic_quick_search_enabled: "הפעלת אפשרות חיפוש סמנטי בחלונית תפריט החיפוש הקופצת." - ai_embeddings_semantic_related_include_closed_topics: "כולל נושאים סגורים בתוצאות החיפוש הסמנטיות" - ai_embeddings_semantic_search_hyde_model: "הדגם שמשמש להרחבת מילות החיפוש לקבלת תוצאות טובות יותר במהלך חיפוש סמנטי" - ai_embeddings_per_post_enabled: יצירת הטמעות בכל פוסט בנפרד - ai_summarization_enabled: "הפעלת יכולת הסיכום" - ai_summarization_model: "מודל לשימוש לסיכום" - ai_summarization_persona: "דמות לשימוש ליכולת הסיכום" - ai_custom_summarization_allowed_groups: "קבוצות שמורשות להשתמש ליצירת סיכומים חדשים." - ai_pm_summarization_allowed_groups: "קבוצות שמורשות ליצור ולצפות בתקצירים בהודעות פרטיות." - ai_summary_gists_enabled: "יצירת תקצירים של התגובות האחרונות בנושאים אוטומטית" - ai_summary_gists_allowed_groups: "קבוצות שמורשות לראות gists ברשימת הנושאים החמים." - ai_summary_backfill_maximum_topics_per_hour: "מספר תקצירי הנושאים למילוי חוזר בשעה." - ai_bot_enabled: "הפעלת מודול בוט הבינה המלאכותית." - ai_bot_enable_chat_warning: "הצגת אזהרה עם פתיחת שיח הודעות פרטיות. אפשר לדרוס את זה על ידי עריכת מחרוזת התרגום: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "כאשר לבוט של GPT יש גישה להודעות הפרטיות, הוא יגיב לחברים בקבוצות האלה." - ai_bot_debugging_allowed_groups: "לאפשר לקבוצות האלו לצפות בכפתור ניפוי שיאות שמציג את בקשת ותגובה הבינה המלאכותית באופן גולמי" - ai_bot_public_sharing_allowed_groups: "לאפשר לקבוצות האלה לשתף הודעות אישיות של בינה למאכותית עם הציבור באמצעות קישור ייחודי גלוי לציבור. הערה: אם האתר שלך דורש כניסה, גם שיתופים ידרשו כניסה." - ai_bot_add_to_header: "הצגת כפתור בכותרת כדי לפתוח הודעה פרטית עם בוט בינה מלאכותית" - ai_bot_github_access_token: "אסימון גישה ל־GitHub לשימוש עם כלי הבינה המלאכותית של GitHub (נחוץ לתמיכה בחיפוש)" - ai_stability_api_key: "מפתח API ל־API של stability.ai" - ai_stability_engine: "מנוע לחילול תמונות לשימוש עבור ה־API של stability.ai" - ai_stability_api_url: "כתובת ל־API של stability.ai" - ai_google_custom_search_api_key: "מפתח API ל־API החיפוש המותאם אישית של Google, ר׳: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX ל־API החיפוש המותאם אישית של Google" - ai_discord_search_enabled: "הפעלת יכולת החיפוש ב־Discord" - ai_discord_app_id: "המזהה של יישום ה־Discord שברצונך לחבר אליו את החיפוש ב־Discord" - ai_discord_app_public_key: "המפתח הציבורי של יישום ה־Discord שברצונך לחבר אליו את החיפוש ב־Discord" - ai_discord_search_mode: "נא לבחור את מצב החיפוש לחיפוש ב־Discord" - ai_discord_search_persona: "הדמות לשימוש בחיפוש Discord." - ai_discord_allowed_guilds: "גילדות (שרתי) Discord בהן מותר לבוט לחפש" - reviewables: - reasons: - flagged_by_toxicity: תוסף הבינה המלאכותית סימן את זה לאחר סיווג כרעיל. - flagged_by_nsfw: תוסף הבינה המלאכותית סימן את זה לאחר סיווג לפחות אחת התמונות כפוגעניות. - reports: - sentiment_analysis: - title: "ניתוח הבעות" - overall_sentiment: - title: "רגש כללי" - description: 'התרשים משווה את מספר הפוסטים שמסווגים כחיוביים או שליליים. אלו מחושבים כאשר ניקוד חיובי או שלילי הוא גדול מסף הניקוד המוגדר. משמעות הדבר היא שפוסטים נייטרליים לא מופיעים. הודעות אישיות מוחרגות גם כן. מסווגות באמצעות „cardiffnlp/twitter-roberta-base-sentiment-latest”' - xaxis: "חיובי(%)" - yaxis: "תאריך" - emotion_admiration: - title: "\U0001F929 הערצה" - description: "פוסטים שסווגו עם הרגש הערצה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_amusement: - title: "\U0001F604 שעשוע" - description: "פוסטים שסווגו עם הרגש שעשוע באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_anger: - title: "\U0001F620 כעס" - description: "פוסטים שסווגו עם הרגש כעס באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_annoyance: - title: "\U0001F612 מורת רוח" - description: "פוסטים שסווגו עם הרגש מורת רוח באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_approval: - title: "\U0001F44D הסכמה" - description: "פוסטים שסווגו עם הרגש הסכמה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_caring: - title: "\U0001F917 אכפתיות" - description: "פוסטים שסווגו עם הרגש אכפתיות באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_confusion: - title: "\U0001F615 בלבול" - description: "פוסטים שסווגו עם הרגש בלבול באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_curiosity: - title: "\U0001F914 סקרנות" - description: "פוסטים שסווגו עם הרגש סקרנות באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_desire: - title: "\U0001F60D תשוקה" - description: "פוסטים שסווגו עם הרגש תשוקה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_disappointment: - title: "\U0001F61E אכזבה" - description: "פוסטים שסווגו עם הרגש אכזבה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_disapproval: - title: "\U0001F44E אי־הסכמה" - description: "פוסטים שסווגו עם הרגש אי־הסכמה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_disgust: - title: "\U0001F922 גועל" - description: "פוסטים שסווגו עם הרגש גועל באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_embarrassment: - title: "\U0001F633 מבוכה" - description: "פוסטים שסווגו עם הרגש מבוכה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_excitement: - title: "\U0001F92A התרגשות" - description: "פוסטים שסווגו עם הרגש התרגשות באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_fear: - title: "\U0001F628 פחד" - description: "פוסטים שסווגו עם הרגש פחד באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_gratitude: - title: "\U0001F64F הכרת תודה" - description: "פוסטים שסווגו עם הרגש הכרת תודה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_grief: - title: "\U0001F622 צער" - description: "פוסטים שסווגו עם הרגש צער באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_joy: - title: "\U0001F60A הנאה" - description: "פוסטים שסווגו עם הרגש הנאה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_love: - title: '❤️ אהבה' - description: "פוסטים שסווגו עם הרגש אהבה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_nervousness: - title: "\U0001F630 עצבנות" - description: "פוסטים שסווגו עם הרגש עצבנות באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_neutral: - title: "\U0001F610 נייטרלי" - description: "פוסטים שסווגו עם הרגש נייטרלי באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_optimism: - title: "\U0001F31F אופטימיות" - description: "פוסטים שסווגו עם הרגש אופטימיות באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_pride: - title: "\U0001F981 גאווה" - description: "פוסטים שסווגו עם הרגש גאווה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_realization: - title: "\U0001F4A1 תובנה" - description: "פוסטים שסווגו עם הרגש תובנה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_relief: - title: "\U0001F60C הקלה" - description: "פוסטים שסווגו עם הרגש הקלה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_remorse: - title: "\U0001F614 חרטה" - description: "פוסטים שסווגו עם הרגש חרטה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_sadness: - title: "\U0001F62D עצב" - description: "פוסטים שסווגו עם הרגש עצב באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - emotion_surprise: - title: "\U0001F632 הפתעה" - description: "פוסטים שסווגו עם הרגש הפתעה באמצעות בינה מלאכותית, בעזרת המודל ‚SamLowe/roberta-base-go_emotions’." - discourse_ai: - ai_artifact: - view_source: "הצגת המקור" - view_changes: "הצגת שינויים" - change_description: "עריכת התיאור" - unknown_model: "דגם בינה מלאכותית לא ידוע" - tools: - custom_name: "%{name} (מותאם אישית)" - presets: - browse_web_jina: - name: "גלישה באינטרנט (jina.ai)" - exchange_rate: - name: "שער חליפין" - stock_quote: - name: "מחיר המניה (AlphaVantage)" - image_generation: - name: "מחולל תמונות Flux‏ (Together.ai)" - empty_tool: - name: "התחלה מאפס…" - name: - characters: "חייב לכלול מספרים, אותיות וקווים תחתיים בלבד" - ai_helper: - errors: - completion_request_failed: "משהו השתבש במהלך הניסיון לספק הצעות. נא לנסות שוב." - prompts: - translate: תרגום ל%{language} - generate_titles: הצעת כותרות לנושא - proofread: הגהת הטקסט - markdown_table: יצירת טבלה ב־Markdown - custom_prompt: "בקשה מותאמת אישית" - explain: "הסבר" - illustrate_post: "איור פוסט" - replace_dates: "תאריכים חכמים" - painter: - attribution: - stable_diffusion_xl: "תמונה מאת Stable Diffusion XL" - dall_e_3: "תמונה מאת DALL-E 3" - image_caption: - attribution: "הכותרת נוספה ע״י בינה מלאכותית" - share_ai: - read_more: "הצגת התמליל המלא" - onebox_title: "דיון בינה מלאכותית עם %{llm_name}" - formatted_excerpt: "דיון בינה מלאכותית עם %{llm_name}:\n %{excerpt}" - title: "%{title} - דיון בינה מלאכותית - %{site_name}" - errors: - not_allowed: "אין לך הרשאה לשתף את הנושא הזה" - other_people_in_pm: "אי אפשר לשתף הודעות פרטיות עם אנשים אחרים באופן ציבורי" - other_content_in_pm: "אי אפשר לשתף הודעות שמכילות פוסטים של אנשים אחרים באופן ציבורי" - failed_to_share: "שיתוף השיחה נכשל" - conversation_deleted: "שיתוף השיחה נמחק בהצלחה" - spam_detection: - flag_reason: "סומן כספאם ע״י בינה מלאכותית של Discourse" - silence_reason: "המשתמש הושתק אוטומטית על ידי בינה מלאכותית של Discourse" - invalid_error_type: "סוג השגיאה שסופק לא תקין" - unexpected: "אירעה שגיאה בלתי צפויה" - ai_bot: - reply_error: "נראה שהמערכת שלך נתקלה בקשיים לא צפויים בעת הניסיון להשיב, עמך הסליחה.\n\n[details='פרטי השגיאה']\n%{details}\n[/details]" - default_pm_prefix: "[הודעה פרטית של בינה מלאכותית ללא כותרת]" - thinking: "בתהליך חשיבה…" - personas: - default_llm_required: "ברירת מחדל של מודל שפה הגדול נחוצה בטרם הפעלת הצ׳אט" - cannot_delete_system_persona: "אי אפשר למחוק דמויות מערכת, נא להשבית אותן במקום" - cannot_edit_system_persona: "לדמויות מערכת אפשר רק לשנות את השם, אסור לערוך כלים או את בקשת הבסיס של המערכת, במקום יש להשבית ולשבט" - cannot_have_duplicate_tools: "לא יכולים להיות כלים כפולים" - github_helper: - name: "מסייע GitHub" - description: "בוט בינה מלאכותית שמסייע במשימות ובשאלות שקשורות ב־GitHub" - general: - name: מסייע פורומים - description: "בוט בינה מלאכותית למטרות כלליות שיכול לבצע מגוון משימות" - artist: - name: אמן - description: "בוט בינה מלאכותית שמתמחה ביצירת תמונות" - designer: - name: מעצב - description: "בוט בינה מלאכותית שמתמחה ביצירת ועריכת תמונות" - sql_helper: - name: מסייע SQL - description: "בוט בינה מלאכותית שמתמחה בסיוע בכתיבת שאילתות SQL במופע ה־Discourse הזה" - settings_explorer: - name: סייר הגדרות - description: "בוט בינה מלאכותית שמתמחה בסיור בהגדרות אתר ה־Discourse" - creative: - name: יצירה - description: "בוט בינה מלאכותית ללא שילובים חיצוניים שמתמחה ביצירת משימות יצירתיות" - dall_e3: - name: "DALL-E 3" - description: "בוט בינה מלאכותית שמתמחה ביצירת תמונות באמצעות DALL-E 3" - discourse_helper: - name: "מסייע Discourse" - description: "בוט בינה מלאכותית שמתמחה בסיוע במשימות שקשורות ב־Discourse" - web_artifact_creator: - name: "יוצר תוצרים דפדפניים" - description: "בוט בינה מלאכותית שמתמחה ביצירת תוצרים אינטרנטיים אינטראקטיביים" - summarizer: - name: "מסכם" - short_summarizer: - name: "מסכם (מקוצר)" - custom_prompt: - name: "בקשה מותאמת אישית" - smart_dates: - name: "תאריכים חכמים" - topic_not_found: "תקציר לא זמין, לא נמצא נושא!" - summarizing: "הנושא מסוכם" - searching: "חיפוש אחר: ‚%{query}’" - tool_options: - researcher: - researcher_llm: - name: "LLM" - max_results: - name: "מספר תוצאות מרבי" - create_artifact: - creator_llm: - name: "LLM" - description: "מודל שפה לשימוש ליצירת תוצרים" - update_artifact: - editor_llm: - name: "LLM" - description: "מודל שפה לשימוש לעריכת תוצרים" - update_algorithm: - name: "עדכון אלגוריתם" - description: "לבקש מ־LLM להחליף לחלוטין או להשתמש ב־diff (הבדלים) כדי לעדכן" - do_not_echo_artifact: - description: "יגביל את העלויות אבל יפגום ביעילות עדכוני התוצרים" - google: - base_query: - name: "שאילתת חיפוש בסיסית" - description: "שאילתת בסיס לשימוש בחיפוש. דוגמאות: ‚site:example.com’ יכלול תוצאות מ־example.com,‏ before:2022-01-01 יכלול תוצאות מ־2021 ולפני כן. הטקסט הזה נוסף לשאילתת החיפוש." - read: - read_private: - name: "קריאה פרטית" - description: "לאפשר גישה לכל הנושאים שיש למשתמש גישה אליהם (כברירת מחדל רק נושאים ציבוריים כלולים)" - search: - search_private: - name: "חיפוש פרטי" - description: "לכלול את כל הנושאים שלמשתמש יש גישה אליהם בתוצאות החיפוש (כברירת המחדל רק נושאים ציבוריים כלולים)" - max_results: - name: "מספר תוצאות מרבי" - description: "המספר המרבי של תוצאות שיכללו בחיפוש - אם ייעשה שימוש בכללי ברירת מחדל ריקים והספירה תשתנה בהתאם לדגם שבשימוש. הערך הגבוה ביותר הוא 100." - base_query: - name: "שאילתת חיפוש בסיסית" - description: "שאילתת בסיס לשימוש בעת חיפוש. למשל: ‚‎#urgent’ יוסיף את ‚‎#urgent’ לשאילתת החיפוש ויכלול רק נושאים עם הקטגוריה או התגית urgent (דחוף)." - tool_summary: - read_artifact: "קריאת תוצר דפדפני" - update_artifact: "עדכון תוצר דפדפני" - create_artifact: "יצירת תוצר דפדפני" - web_browser: "גלישה באינטרנט" - github_search_files: "חיפוש קבצים ב־GitHub" - github_search_code: "חיפוש קוד ב־GitHub" - github_file_content: "תוכן קובץ ב־GitHub" - github_pull_request_diff: "הבדל בבקשת דחיפה ב־GitHub" - random_picker: "בורר אקראי" - categories: "הצגת קטגוריות" - search: "חיפוש" - tags: "הצגת תגיות" - time: "מועד" - summarize: "סיכום" - image: "יצירת תמונה" - google: "חיפוש ב־Google" - read: "קריאת נושא" - setting_context: "חיפוש הקשר הגדרות אתר" - schema: "חיפוש סכמת מסד נתונים" - search_settings: "מתבצע חיפוש בהגדרות האתר" - dall_e: "יצירת תמונה" - search_meta_discourse: "חיפוש ב־Meta Discrouse" - javascript_evaluator: "שערוך JavaScript" - create_image: "יצירת תמונה" - edit_image: "עריכת תמונה" - researcher_dry_run: "המחקר בהכנה" - tool_help: - read_artifact: "לקרוא תוצר דפדפני באמצעות בוט הבינה המלאכותית" - update_artifact: "עדכון תוצר דפדפני באמצעות בוט הבינה המלאכותית" - create_artifact: "יצירת תוצר דפדפני באמצעות בוט הבינה המלאכותית" - web_browser: "גלישה באינטרנט באמצעות בוט בינה מלאכותית" - github_search_code: "חיפוש אחר קוד במאגר GitHub" - github_search_files: "חיפוש אחר קבצים במאגר GitHub" - github_file_content: "משיכת תוכן של קבצים ממאגר ב־GitHub" - github_pull_request_diff: "משיכת הבדל לבקשת דחיפה ב־GitHub" - random_picker: "נא לבחור מספר אקראי או רכיב אקראי מרשימה" - categories: "הצגת כל הקטגוריות החשופות לציבור בפורום" - search: "חיפוש בכל הנושאים הציבוריים בפורום" - tags: "הצגת כל התגיות בפורום" - time: "איתור זמן באזורי זמן שונים" - summary: "סיכום נושא" - image: "יצירת תמונה באמצעות Stable Diffusion" - create_image: "יצירת תמונה עם מודל התמונות של GPT מבית OpenAI" - edit_image: "עריכת תמונה עם מודל התמונות של GPT מבית OpenAI" - google: "חיפוש שאילתה ב־Google" - read: "הצגת נושא ציבורי בפורום" - setting_context: "חיפוש הקשר הגדרות אתר" - schema: "חיפוש סכמת מסד נתונים" - search_settings: "חיפוש בהגדרות האתר" - dall_e: "יצירת תמונה באמצעות DALL-E 3" - search_meta_discourse: "חיפוש ב־Meta Discrouse" - javascript_evaluator: "שערוך JavaScript" - tool_description: - read_artifact: "לקרוא תוצר דפדפני באמצעות בוט הבינה המלאכותית" - update_artifact: "עודכן תוצר דפדפני באמצעות בוט הבינה המלאכותית" - create_artifact: "נוצר תוצר דפדפני: %{name} - %{specification}" - web_browser: "קורא את %{url}" - github_search_files: "בוצע חיפוש אחר ‚%{keywords}’ בתוך %{repo}/%{branch}" - github_search_code: "בוצע חיפוש אחר ‚%{query}’ בתוך %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "התקבל תוכן של %{file_paths} מתוך %{repo_name}@%{branch}" - random_picker: "בחירה מתוך %{options}, נבחר: %{result}" - read: "קריאה: %{title}" - time: "השעה ב־%{timezone} היא %{time}" - summarize: "%{title} סוכם" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "נמצאה קטגוריה" - two: "נמצאו שתי קטגוריות" - many: "נמצאו %{count} קטגוריות" - other: "נמצאו %{count} קטגוריות" - tags: - one: "נמצאה תגית" - two: "נמצאו שתי תגיות" - many: "נמצאו %{count} תגיות" - other: "נמצאו %{count} תגיות" - search: - one: "נמצאה תוצאה לחיפוש אחר ‚%{query}’" - two: "נמצאו שתי תוצאות לחיפוש אחר ‚%{query}’" - many: "נמצאו %{count} תוצאות לחיפוש אחר ‚%{query}’" - other: "נמצאו %{count} תוצאות לחיפוש אחר ‚%{query}’" - search_meta_discourse: - one: "נמצאה תוצאה לחיפוש אחר ‚%{query}’" - two: "נמצאו שתי תוצאות לחיפוש אחר ‚%{query}’" - many: "נמצאו %{count} תוצאות לחיפוש אחר ‚%{query}’" - other: "נמצאו %{count} תוצאות לחיפוש אחר ‚%{query}’" - google: - one: "נמצאה תוצאה לחיפוש אחר ‚%{query}’" - two: "נמצאו שתי תוצאות לחיפוש אחר ‚%{query}’" - many: "נמצאו %{count} תוצאות לחיפוש אחר ‚%{query}’" - other: "נמצאו %{count} תוצאות לחיפוש אחר ‚%{query}’" - setting_context: "נקרא הקשר עבור: %{setting_name}" - schema: "%{tables}" - researcher_dry_run: - one: "יעדים מוצעים: %{goals}\n\nנמצא פוסט שעונה על ‚%{filter}’" - two: "יעדים מוצעים: %{goals}\n\nנמצאו שני פוסטים שעונים על ‚%{filter}’" - many: "יעדים מוצעים: %{goals}\n\nנמצאו %{count} פוסטים שעונים על ‚%{filter}’" - other: "יעדים מוצעים: %{goals}\n\nנמצאו %{count} פוסטים שעונים על ‚%{filter}’" - researcher: - one: "במהלך מחקר: %{goals}\n\nנמצא פוסט שעונה על ‚%{filter}’" - two: "במהלך מחקר: %{goals}\n\nנמצאו שני פוסטים שעונים על ‚%{filter}’" - many: "במהלך מחקר: %{goals}\n\nנמצאו %{count} פוסטים שעונים על ‚%{filter}’" - other: "במהלך מחקר: %{goals}\n\nנמצאו %{count} פוסטים שעונים על ‚%{filter}’" - search_settings: - one: "נמצאה תוצאה לחיפוש אחר ‚%{query}’" - two: "נמצאו שתי תוצאות לחיפוש אחר ‚%{query}’" - many: "נמצאו %{count} תוצאות לחיפוש אחר ‚%{query}’" - other: "נמצאו %{count} תוצאות לחיפוש אחר ‚%{query}’" - summarization: - configuration_hint: - one: "יש להגדיר את ההגדרה `%{setting}` תחילה." - two: "יש להגדיר את ההגדרות האלו תחילה: %{settings}." - many: "יש להגדיר את ההגדרות האלו תחילה: %{settings}." - other: "יש להגדיר את ההגדרות האלו תחילה: %{settings}." - chat: - no_targets: "לא היו הודעות במהלך התקופה הנבחרת." - sentiment: - reports: - overall_sentiment: "חוויה כוללת (חיובית - שלילית)" - post_emotion: - sadness: "עצב \U0001F622" - surprise: "הפתעה \U0001F631" - neutral: "נייטרלי \U0001F610" - fear: "פחד \U0001F628" - anger: "כעס \U0001F621" - joy: "הנאה \U0001F600" - disgust: "גועל \U0001F922" - sentiment_analysis: - positive: "חיובי" - negative: "שלילי" - neutral: "נייטרלי" - llm: - configuration: - disable_module_first: "קודם צריך להשבית את %{setting}." - set_llm_first: "קודם יש להגדיר את %{setting}" - model_unreachable: "לא הצלחנו לקבל תגובה מהמודל הזה. נא לוודא שההגדרות שלך נכונות קודם." - invalid_seeded_model: "אי אפשר להשתמש במודל הזה עם היכולת הזאת" - must_select_model: "קודם יש לבחור LLM" - endpoints: - not_configured: "%{display_name} (לא מוגדר)" - configuration_hint: - one: "נא לוודא שההגדרה `%{settings}` הוגדרה." - two: "נא לוודא שההגדרות האלו הוגדרו: %{settings}" - many: "נא לוודא שההגדרות האלו הוגדרו: %{settings}" - other: "נא לוודא שההגדרות האלו הוגדרו: %{settings}" - delete_failed: - one: "לא הצלחנו למחוק את המודל הזה כי %{settings} משתמש בו. נא לעדכן את ההגדרות לנסות שוב." - two: "לא הצלחנו למחוק את המודל הזה כי %{settings} משתמשים בו. נא לעדכן את ההגדרות לנסות שוב." - many: "לא הצלחנו למחוק את המודל הזה כי %{settings} משתמשים בו. נא לעדכן את ההגדרות לנסות שוב." - other: "לא הצלחנו למחוק את המודל הזה כי %{settings} משתמשים בו. נא לעדכן את ההגדרות לנסות שוב." - cannot_edit_builtin: "אי אפשר לערוך מודל מובנה." - embeddings: - delete_failed: "זה המודל שנמצא בשימוש. יש לעדכן את `ai embeddings selected model`." - cannot_edit_builtin: "אי אפשר לערוך מודל מובנה." - configuration: - disable_embeddings: "יש להשבית קודם את ‚הטמעת בינה מלאכותית פעילה’." - invalid_config: "בחרת אפשרות שגויה." - choose_model: "יש לעדכן את ‚ai embeddings selected model’." - llm_models: - missing_provider_param: "%{param} לא יכול להישאר ריק" - bedrock_invalid_url: "נא למלא את כל השדות די להשתמש במודל הזה." - ai_staff_action_logger: - updated: "עודכן" - removed: "הוסרה" - errors: - quota_exceeded: "חרגת מהמכסה למודל הזה. נא לנסות שוב בעוד %{relative_time}." - quota_required: "יש לציין את כמות האסימונים המרבית לשימוש למודל הזה" - no_query_specified: משתנה השאילתה נחוץ, נא לציין אותו. - no_user_for_persona: לדמות שצוינה אין משתמש שמשויך אליה. - persona_not_found: הדמות שצוינה לא קיימת. נא לבדוק את המשתנים persona_name או persona_id. - no_user_specified: המשתנים username או user_unique_id נחוצים, נא לציין לפחות אחד מהם. - user_not_found: המשתמש שצוין לא קיים. נא לבדוק את המשתנה username. - persona_disabled: הדמות שצוינה הושבתה. נא לבדוק את המשתנים persona_name או persona_id. - no_default_llm: לדמות חייב להיות מוגדר default_llm (מודל ברירת מחדל). - user_not_allowed: המשתמש לא מורשה להשתתף בנושא. - prompt_message_length: ההודעה %{idx} חורגת ממגבלת 1000 התווים. - dashboard: - problem: - ai_llm_status: "מודל השפה הגדול: %{model_name} נתקל בקשיים. נא לגשת לעמוד ההגדרות של המודל." diff --git a/config/locales/server.hr.yml b/config/locales/server.hr.yml deleted file mode 100644 index a6f10423..00000000 --- a/config/locales/server.hr.yml +++ /dev/null @@ -1,19 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -hr: - reports: - overall_sentiment: - yaxis: "Datum" - discourse_ai: - ai_bot: - tool_summary: - search: "Pretraživanje" - time: "Vrijeme" - summarize: "Rezimirati" - ai_staff_action_logger: - updated: "ažurirano" - removed: "uklonjeno" diff --git a/config/locales/server.hu.yml b/config/locales/server.hu.yml deleted file mode 100644 index a91618d1..00000000 --- a/config/locales/server.hu.yml +++ /dev/null @@ -1,28 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -hu: - reports: - overall_sentiment: - yaxis: "Dátum" - emotion_neutral: - title: "\U0001F610 Semleges" - discourse_ai: - ai_bot: - tool_summary: - search: "Keresés" - tags: "Címkék listázása" - time: "Idő" - summarize: "Összefoglalás" - sentiment: - reports: - post_emotion: - neutral: "Semleges \U0001F610" - sentiment_analysis: - neutral: "Semleges" - ai_staff_action_logger: - updated: "frissítve" - removed: "eltávolítva" diff --git a/config/locales/server.hy.yml b/config/locales/server.hy.yml deleted file mode 100644 index 779f3871..00000000 --- a/config/locales/server.hy.yml +++ /dev/null @@ -1,26 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -hy: - reports: - overall_sentiment: - yaxis: "Ամսաթիվ" - emotion_neutral: - title: "\U0001F610 Նեյտրալ" - discourse_ai: - ai_bot: - tool_summary: - search: "Որոնում" - time: "Ժամ" - sentiment: - reports: - post_emotion: - neutral: "Նեյտրալ \U0001F610" - sentiment_analysis: - neutral: "Նեյտրալ" - ai_staff_action_logger: - updated: "թարմացված" - removed: "հեռացվել է" diff --git a/config/locales/server.id.yml b/config/locales/server.id.yml deleted file mode 100644 index 4efe05f1..00000000 --- a/config/locales/server.id.yml +++ /dev/null @@ -1,75 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -id: - discourse_automation: - scriptables: - llm_triage: - title: Triase posting menggunakan AI - description: "Triase posting menggunakan model bahasa besar" - llm_report: - title: Laporan berkala menggunakan AI - description: "Laporan berkala berdasarkan model bahasa besar" - site_settings: - discourse_ai_enabled: "Aktifkan plugin AI discourse." - ai_toxicity_enabled: "Aktifkan modul toksisitas." - ai_toxicity_inference_service_api_endpoint: "URL tempat API berjalan untuk modul toxicity" - ai_toxicity_inference_service_api_key: "Kunci API untuk API toxicity" - ai_toxicity_inference_service_api_model: "Model yang digunakan untuk inferensi. Model multibahasa dapat digunakan dengan bahasa Italia, Prancis, Rusia, Portugis, Spanyol, dan Turki." - ai_toxicity_flag_automatically: "Secara otomatis menandai posting/pesan obrolan yang berada di atas ambang batas yang dikonfigurasi." - ai_toxicity_flag_threshold_toxicity: "Toxicity: komentar kasar, tidak sopan, atau tidak masuk akal yang kemungkinan besar akan membuat Anda meninggalkan diskusi atau menyerah dalam menyampaikan sudut pandang Anda" - ai_toxicity_flag_threshold_severe_toxicity: "Toxicity Parah: komentar yang sangat penuh kebencian, agresif, atau tidak sopan yang kemungkinan besar akan membuat Anda meninggalkan diskusi atau menyerah dalam membagikan sudut pandang Anda" - ai_toxicity_flag_threshold_obscene: "Tidak senonoh" - ai_toxicity_flag_threshold_identity_attack: "Serangan Identitas" - ai_toxicity_flag_threshold_insult: "Penghinaan" - ai_toxicity_flag_threshold_threat: "Ancaman" - ai_toxicity_flag_threshold_sexual_explicit: "Eksplisit Seksual" - ai_toxicity_groups_bypass: "Posting pengguna di grup tersebut tidak akan diklasifikasikan berdasarkan modul toxicity." - ai_sentiment_enabled: "Aktifkan modul sentiment." - ai_sentiment_inference_service_api_endpoint: "URL tempat API berjalan untuk modul sentiment" - ai_sentiment_inference_service_api_key: "Kunci API untuk API sentiment" - ai_sentiment_models: "Model yang digunakan untuk inferensi. Sentiment menglasifikasikan posting pada ruang positif/netral/negatif. Emosi dikelompokkan ke dalam ruang marah/jijik/takut/gembira/netral/sedih/kejutan." - ai_nsfw_detection_enabled: "Aktifkan modul NSFW." - ai_nsfw_inference_service_api_endpoint: "URL tempat API beroperasi untuk modul NSFW" - ai_nsfw_inference_service_api_key: "Kunci API untuk API NSFW" - ai_nsfw_flag_automatically: "Secara otomatis menandai postingan NSFW yang berada di atas ambang batas yang dikonfigurasi." - ai_nsfw_flag_threshold_general: "Ambang Batas Umum agar suatu gambar dianggap NSFW." - ai_nsfw_models: "Model yang digunakan untuk inferensi NSFW." - ai_bot_public_sharing_allowed_groups: "Izinkan grup-grup ini untuk membagikan pesan pribadi AI kepada publik melalui tautan unik yang tersedia untuk umum. Catatan: jika situs Anda memerlukan login, berbagi juga akan memerlukan login." - reports: - overall_sentiment: - yaxis: "Tanggal" - discourse_ai: - ai_artifact: - view_changes: "Lihat Perubahan" - ai_helper: - prompts: - custom_prompt: "Perintah Khusus" - image_caption: - attribution: "Keterangan oleh AI" - ai_bot: - default_pm_prefix: "[PM bot AI tanpa judul]" - personas: - default_llm_required: "Model LLM default diperlukan sebelum mengaktifkan Obrolan" - tool_options: - search: - search_private: - name: "Pencarian Pribadi" - description: "Sertakan semua topik yang dapat diakses pengguna dalam hasil pencarian (secara bawaan hanya topik publik yang disertakan)" - tool_summary: - github_search_files: "File pencarian GitHub" - search: "Cari" - time: "Waktu" - summarize: "Meringkas" - tool_help: - github_search_files: "Cari file di repositori GitHub" - summary: "Meringkas suatu topik" - tool_description: - github_search_files: "Mencari '%{keywords}' di %{repo}/%{branch}" - github_search_code: "Mencari '%{query}' di %{repo}" - summarization: - chat: - no_targets: "Tidak ada pesan selama periode yang dipilih." diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml deleted file mode 100644 index 33527bfa..00000000 --- a/config/locales/server.it.yml +++ /dev/null @@ -1,445 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -it: - discourse_automation: - ai: - flag_types: - review: "Aggiungi il messaggio alla coda di revisione" - spam: "Segnala come spam e nascondi il post" - spam_silence: "Segnala come spam, nascondi il post e silenzia l'utente" - scriptables: - llm_triage: - title: Triage dei messaggi con IA - description: "Triage dei messaggi con LLM" - flagged_post: | -
Risposta dal modello:
-

%%LLM_RESPONSE%%

- Innescato dalla regola %%AUTOMATION_NAME%%. - llm_report: - title: Report periodico con IA - description: "Report periodico basato su LLM" - site_settings: - discourse_ai_enabled: "Abilita il plugin Discourse AI." - ai_toxicity_enabled: "Abilita il modulo tossicità." - ai_toxicity_inference_service_api_endpoint: "URL in cui l'API è in esecuzione per il modulo tossicità" - ai_toxicity_inference_service_api_key: "Chiave API per l'API di tossicità" - ai_toxicity_inference_service_api_model: "Modello da utilizzare per l'inferenza. Il modello multilingue funziona con italiano, francese, russo, portoghese, spagnolo e turco." - ai_toxicity_flag_automatically: "Contrassegna automaticamente post/messaggi di chat al di sopra delle soglie configurate." - ai_toxicity_flag_threshold_toxicity: "Tossicità: un commento maleducato, irrispettoso o irragionevole che potrebbe farti abbandonare una discussione o rinunciare a condividere il tuo punto di vista" - ai_toxicity_flag_threshold_severe_toxicity: "Grave tossicità: un commento molto odioso, aggressivo o irrispettoso che molto probabilmente ti farà abbandonare una discussione o rinunciare a condividere la tua prospettiva" - ai_toxicity_flag_threshold_obscene: "Osceno" - ai_toxicity_flag_threshold_identity_attack: "Attacco all'identità" - ai_toxicity_flag_threshold_insult: "Insulto" - ai_toxicity_flag_threshold_threat: "Minaccia" - ai_toxicity_flag_threshold_sexual_explicit: "Sessualmente esplicito" - ai_toxicity_groups_bypass: "Gli utenti di questi gruppi non vedranno i propri post classificati dal modulo sulla tossicità." - ai_sentiment_enabled: "Abilita il modulo sentimento." - ai_sentiment_inference_service_api_endpoint: "URL in cui l'API è in esecuzione per il modulo sentimento" - ai_sentiment_inference_service_api_key: "Chiave API per l'API di sentimento" - ai_sentiment_models: "Modelli da utilizzare per l'inferenza. Il sentimento classifica i messaggi nello spazio positivo/neutro/negativo. L'emozione si classifica nello spazio rabbia/disgusto/paura/gioia/neutrale/tristezza/sorpresa." - ai_nsfw_detection_enabled: "Abilita il modulo NSFW." - ai_nsfw_inference_service_api_endpoint: "URL in cui l'API è in esecuzione per il modulo NSFW" - ai_nsfw_inference_service_api_key: "Chiave API per l'API NSFW" - ai_nsfw_flag_automatically: "Contrassegna automaticamente i post NSFW che superano le soglie configurate." - ai_nsfw_flag_threshold_general: "Soglia generale per un'immagine da considerare NSFW." - ai_nsfw_flag_threshold_drawings: "Soglia per un disegno da considerare NSFW." - ai_nsfw_flag_threshold_hentai: "Soglia per un'immagine classificata come hentai per essere considerata NSFW." - ai_nsfw_flag_threshold_porn: "Soglia per un'immagine classificata come pornografica per essere considerata NSFW." - ai_nsfw_flag_threshold_sexy: "Soglia per un'immagine classificata come sexy per essere considerata NSFW." - ai_nsfw_models: "Modelli da utilizzare per l'inferenza NSFW." - ai_helper_enabled: "Abilita l'assistente IA." - composer_ai_helper_allowed_groups: "Gli utenti di questi gruppi vedranno il pulsante dell'assistente IA nella sezione di scrittura." - ai_helper_allowed_in_pm: "Abilita l'assistente IA nei MP." - ai_helper_model: "Modello da utilizzare per l'assistente IA." - ai_helper_custom_prompts_allowed_groups: "Gli utenti di questi gruppi vedranno l'opzione di comando personalizzato nell'assistente IA." - ai_helper_automatic_chat_thread_title_delay: "Ritardo in minuti prima che l'assistente IA imposti automaticamente il titolo del thread della chat." - ai_helper_automatic_chat_thread_title: "Imposta automaticamente i titoli dei thread della chat in base ai contenuti del thread." - ai_helper_illustrate_post_model: "Modello da utilizzare per la funzionalità di messaggio illustrato dell'assistente IA compositore" - ai_helper_enabled_features: "Seleziona le funzionalità da abilitare nell'assistente IA." - post_ai_helper_allowed_groups: "Gruppi di utenti autorizzati ad accedere alle funzionalità dell'assistente IA nei post" - ai_helper_image_caption_model: "Seleziona il modello da utilizzare per generare le didascalie delle immagini" - ai_auto_image_caption_allowed_groups: "Gli utenti di questi gruppi possono attivare/disattivare i sottotitoli automatici delle immagini." - ai_embeddings_selected_model: "Usa il modello selezionato per generare le integrazioni." - ai_embeddings_generate_for_pms: "Genera integrazioni per messaggi personali." - ai_embeddings_semantic_related_topics_enabled: "Usa la ricerca semantica per argomenti correlati." - ai_embeddings_semantic_related_topics: "Numero massimo di argomenti da mostrare nella sezione degli argomenti correlati." - ai_embeddings_backfill_batch_size: "Numero di incorporamenti da riempire ogni 15 minuti." - ai_embeddings_semantic_search_enabled: "Abilita la ricerca semantica a pagina intera." - ai_embeddings_semantic_quick_search_enabled: "Abilita l'opzione di ricerca semantica nel popup del menu di ricerca." - ai_embeddings_semantic_related_include_closed_topics: "Includi argomenti chiusi nei risultati della ricerca semantica" - ai_embeddings_semantic_search_hyde_model: "Modello utilizzato per espandere le parole chiave per ottenere risultati migliori durante una ricerca semantica" - ai_embeddings_per_post_enabled: Genera integrazioni per ogni post - ai_summarization_model: "Modello da utilizzare per il riepilogo" - ai_custom_summarization_allowed_groups: "I gruppi autorizzati a utilizzare la creazione di nuovi riepiloghi." - ai_pm_summarization_allowed_groups: "Gruppi autorizzati a creare e visualizzare riepiloghi nei messaggi privati." - ai_summary_gists_enabled: "Genera automaticamente brevi riepiloghi delle ultime risposte negli argomenti" - ai_summary_gists_allowed_groups: "Gruppi autorizzati a visualizzare i concetti chiave nell'elenco degli argomenti più popolari." - ai_summary_backfill_maximum_topics_per_hour: "Numero di riepiloghi degli argomenti da riempire all'ora." - ai_bot_enabled: "Abilita il modulo Bot IA." - ai_bot_enable_chat_warning: "Visualizza un avviso quando viene avviata la chat MP. Può essere sovrascritto modificando la stringa di traduzione: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Quando il bot GPT ha accesso a un MP, risponderà ai membri di questi gruppi." - ai_bot_debugging_allowed_groups: "Consenti a questi gruppi di vedere un pulsante di debug sui post che mostra la richiesta e la risposta IA non elaborate" - ai_bot_public_sharing_allowed_groups: "Consenti a questi gruppi di condividere messaggi personali dell'IA con il pubblico tramite un link univoco disponibile al pubblico. Nota: se il tuo sito richiede l'accesso, anche le condivisioni richiederanno l'accesso." - ai_bot_add_to_header: "Mostra un pulsante nell'intestazione per avviare un MP con un Bot IA" - ai_bot_github_access_token: "Token di accesso GitHub da utilizzare con gli strumenti GitHub IA (richiesto per il supporto della ricerca)" - ai_stability_api_key: "Chiave API per l'API stability.ai" - ai_stability_engine: "Sistema di generazione di immagini da utilizzare per l'API stability.ai" - ai_stability_api_url: "URL per l'API stability.ai" - ai_google_custom_search_api_key: "Chiave API per l'API della ricerca personalizzata di Google: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX per l'API di ricerca personalizzata di Google" - reviewables: - reasons: - flagged_by_toxicity: Il plugin IA lo ha segnalato dopo averlo classificato come tossico. - flagged_by_nsfw: Il plugin IA lo ha segnalato dopo aver classificato almeno una delle immagini allegate come NSFW. - reports: - overall_sentiment: - title: "Sentimento generale" - description: 'Il grafico confronta il numero di messaggi classificati come positivi o negativi. Questi vengono calcolati quando i punteggi positivi o negativi > il punteggio soglia impostato. Ciò significa che i messaggi neutri non vengono mostrati. Sono esclusi anche i messaggi personali (MP). Classificazione effettuata con "cardiffnlp/twitter-roberta-base-sentiment-latest"' - xaxis: "Positiva(%)" - yaxis: "Data" - emotion_admiration: - title: "\U0001F929 Ammirazione" - description: "Messaggi classificati con l'emozione ammirazione tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_amusement: - title: "\U0001F604 Divertimento" - description: "Messaggi classificati con l'emozione divertimento tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_anger: - title: "\U0001F620 Rabbia" - description: "Messaggi classificati con l'emozione rabbia tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_annoyance: - title: "\U0001F612 Fastidio" - description: "Messaggi classificati con l'emozione fastidio tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_approval: - title: "\U0001F44D Approvazione" - description: "Messaggi classificati con l'emozione approvazione tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_caring: - title: "\U0001F917 Premuroso" - description: "Messaggi classificati con l'emozione premuroso tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_confusion: - title: "\U0001F615 Confusione" - description: "Messaggi classificati con l'emozione confusione tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_curiosity: - title: "\U0001F914 Curiosità" - description: "Messaggi classificati con l'emozione curiosità tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_desire: - title: "\U0001F60D Desiderio" - description: "Messaggi classificati con l'emozione desiderio tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_disappointment: - title: "\U0001F61E Delusione" - description: "Messaggi classificati con l'emozione delusione tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_disapproval: - title: "\U0001F44E Disapprovazione" - description: "Messaggi classificati con l'emozione disapprovazione tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_disgust: - title: "\U0001F922 Disgusto" - description: "Messaggi classificati con l'emozione disgusto tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_embarrassment: - title: "\U0001F633 Imbarazzo" - description: "Messaggi classificati con l'emozione imbarazzo tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_excitement: - title: "\U0001F92A Eccitazione" - description: "Messaggi classificati con l'emozione eccitazione tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_fear: - title: "\U0001F628 Paura" - description: "Messaggi classificati con l'emozione paura tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_gratitude: - title: "\U0001F64F Gratitudine" - description: "Messaggi classificati con l'emozione gratitudine tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_grief: - title: "\U0001F622 Dolore" - description: "Messaggi classificati con l'emozione dolore tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_joy: - title: "\U0001F60A Gioia" - description: "Messaggi classificati con l'emozione gioia tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_love: - title: '❤️ Amore' - description: "Messaggi classificati con l'emozione amore tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_nervousness: - title: "\U0001F630 Nervosismo" - description: "Messaggi classificati con l'emozione nervosismo tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_neutral: - title: "\U0001F610 Neutrale" - description: "Messaggi classificati con l'emozione neutrale tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_optimism: - title: "\U0001F31F Ottimismo" - description: "Messaggi classificati con l'emozione ottimismo tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_pride: - title: "\U0001F981 Orgoglio" - description: "Messaggi classificati con l'emozione orgoglio tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_realization: - title: "\U0001F4A1 Realizzazione" - description: "Messaggi classificati con l'emozione realizzazione tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_relief: - title: "\U0001F60C Sollievo" - description: "Messaggi classificati con l'emozione sollievo tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_remorse: - title: "\U0001F614 Rimorso" - description: "Messaggi classificati con l'emozione rimorso tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_sadness: - title: "\U0001F62D Tristezza" - description: "Messaggi classificati con l'emozione tristezza tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - emotion_surprise: - title: "\U0001F632 Sorpresa" - description: "Messaggi classificati con l'emozione sorpresa tramite intelligenza artificiale, utilizzando il modello 'SamLowe/roberta-base-go_emotions'." - discourse_ai: - ai_artifact: - view_source: "Visualizza origine" - view_changes: "Visualizza modifiche" - unknown_model: "Modello IA sconosciuto" - tools: - custom_name: "%{name} (personalizzato)" - presets: - browse_web_jina: - name: "Naviga sul web (jina.ai)" - exchange_rate: - name: "Tasso di cambio" - stock_quote: - name: "Quotazione azionaria (AlphaVantage)" - image_generation: - name: "Generatore di immagini di flusso (Together.ai)" - empty_tool: - name: "Inizia da zero..." - ai_helper: - errors: - completion_request_failed: "Qualcosa è andato storto durante il tentativo di fornire suggerimenti. Riprova." - prompts: - translate: Traduci in %{language} - generate_titles: Suggerisci i titoli degli argomenti - proofread: Testo corretto - markdown_table: Genera tabella di markdown - custom_prompt: "Comando personalizzato" - explain: "Spiega" - illustrate_post: "Illustra messaggio" - replace_dates: "Date smart" - painter: - attribution: - stable_diffusion_xl: "Immagine di Stable Diffusion XL" - dall_e_3: "Immagine di DALL-E 3" - image_caption: - attribution: "Didascalia da IA" - share_ai: - read_more: "Leggi la trascrizione completa" - onebox_title: "Conversazione IA con %{llm_name}" - formatted_excerpt: "Conversazione IA con %{llm_name}:\n %{excerpt}" - title: "%{title} - Conversazione IA - %{site_name}" - errors: - not_allowed: "Non hai l'autorizzazione a condividere questo argomento" - other_people_in_pm: "I messaggi personali con altri esseri umani non possono essere condivisi pubblicamente" - other_content_in_pm: "I messaggi personali contenenti post di altre persone non possono essere condivisi pubblicamente" - failed_to_share: "Condivisione della conversazione non riuscita" - conversation_deleted: "Condivisione di conversazione eliminata correttamente" - spam_detection: - flag_reason: "Segnalato come spam da Discourse AI" - silence_reason: "Utente silenziato automaticamente da Discourse AI" - invalid_error_type: "Tipo di errore non valido fornito" - unexpected: "Si è verificato un errore imprevisto" - bot_user_update_failed: "Impossibile aggiornare l'utente del bot di scansione antispam" - ai_bot: - reply_error: "Spiacenti, il nostro sistema ha riscontrato un problema imprevisto durante il tentativo di risposta.\n\n[details='Dettagli errore']\n%{details}\n[/details]" - default_pm_prefix: "[Bot IA senza titolo MP]" - personas: - default_llm_required: "Il modello LLM predefinito è obbligatorio prima di abilitare la chat" - cannot_delete_system_persona: "I personaggi di sistema non possono essere eliminati, vanno invece disattivati" - cannot_edit_system_persona: "I personaggi di sistema possono solo essere rinominati, non è possibile modificare strumenti di sistema, piuttosto disabilitali e crearne una copia" - github_helper: - name: "Assistente GitHub" - description: "Bot IA specializzato nell'assistenza con attività e domande relative a GitHub" - general: - name: Assistente del forum - description: "Bot IA per scopi generici in grado di eseguire vari compiti" - artist: - name: Artista - description: "Bot IA specializzato nella generazione di immagini" - sql_helper: - name: Assistente SQL - description: "Bot IA specializzato nell'aiutare a creare query SQL su questa istanza di Discourse" - settings_explorer: - name: Esploratore di impostazioni - description: "Bot IA specializzato nell'aiutare a esplorare le impostazioni del sito Discourse" - creative: - name: Creativo - description: "Bot IA senza integrazioni esterne specializzato in attività creative" - dall_e3: - name: "DALL-E 3" - description: "Bot IA specializzato nella generazione di immagini tramite DALL-E 3" - discourse_helper: - name: "Assistente di Discourse" - description: "Bot IA specializzato nell'aiutare con attività relative a Discourse" - web_artifact_creator: - name: "Creatore di artefatti web" - description: "Bot IA specializzato nella creazione di artefatti web interattivi" - custom_prompt: - name: "Comando personalizzato" - smart_dates: - name: "Date smart" - topic_not_found: "Riepilogo non disponibile, argomento non trovato!" - summarizing: "Riepilogo argomento" - searching: "Ricerca di: '%{query}'" - tool_options: - researcher: - max_results: - name: "Numero massimo di risultati" - google: - base_query: - name: "Query di ricerca di base" - description: "Query di base da usare durante la ricerca. Esempi: 'site:example.com' includerà solo i risultati di example.com, before:2022-01-01 includerà solo i risultati del 2021 e precedenti. Questo testo è anteposto alla query di ricerca." - read: - read_private: - name: "Leggi privato" - description: "Consenti l'accesso a tutti gli argomenti a cui l'utente ha accesso (per impostazione predefinita sono inclusi solo gli argomenti pubblici)" - search: - search_private: - name: "Cerca privato" - description: "Includi tutti gli argomenti a cui l'utente ha accesso nei risultati di ricerca (per impostazione predefinita sono inclusi solo gli argomenti pubblici)" - max_results: - name: "Numero massimo di risultati" - description: "Numero massimo di risultati da includere nella ricerca: se vuoto verranno utilizzate le regole predefinite e il conteggio verrà scalato in base al modello utilizzato. Il valore più alto è 100." - base_query: - name: "Query di ricerca di base" - description: "Query di base da utilizzare durante la ricerca. Esempio: \"#urgente\" anteporrà \"#urgente\" alla query di ricerca e includerà solo gli argomenti con la categoria o l'etichetta urgente." - tool_summary: - update_artifact: "Aggiorna un artefatto web" - create_artifact: "Crea artefatto web" - web_browser: "Naviga sul web" - github_search_files: "File di ricerca GitHub" - github_search_code: "Ricerca codice GitHub" - github_file_content: "Contenuto del file GitHub" - github_pull_request_diff: "Diff richiesta pull GitHub" - random_picker: "Selettore casuale" - categories: "Elenca le categorie" - search: "Cerca" - tags: "Elenca le etichette" - time: "Ora" - summarize: "Riassumi" - image: "Genera immagine" - google: "Cerca su Google" - read: "Leggi l'argomento" - setting_context: "Cerca il contesto delle impostazioni del sito" - schema: "Cerca lo schema del database" - search_settings: "Ricerca nelle impostazioni del sito" - dall_e: "Genera immagine" - search_meta_discourse: "Cerca Meta Discourse" - javascript_evaluator: "Valuta JavaScript" - tool_help: - update_artifact: "Aggiorna un artefatto web utilizzando il bot IA" - create_artifact: "Crea un artefatto web utilizzando il bot IA" - web_browser: "Sfoglia la pagina web utilizzando il Bot IA" - github_search_code: "Cerca il codice in un repository GitHub" - github_search_files: "Cerca file in un repository GitHub" - github_file_content: "Recupera il contenuto dei file da un repository GitHub" - github_pull_request_diff: "Recupera un diff della richiesta pull GitHub" - random_picker: "Scegli un numero casuale o un elemento casuale di una lista" - categories: "Elenca tutte le categorie visibili pubblicamente sul forum" - search: "Cerca tutti gli argomenti pubblici sul forum" - tags: "Elenca tutte le etichette sul forum" - time: "Trova l'ora in diversi fusi orari" - summary: "Riassumi un argomento" - image: "Genera un'immagine utilizzando la diffusione stabile" - google: "Cerca una query su Google" - read: "Leggi l'argomento pubblico sul forum" - setting_context: "Cerca il contesto delle impostazioni del sito" - schema: "Cerca lo schema del database" - search_settings: "Cerca le impostazioni del sito" - dall_e: "Genera immagine utilizzando DALL-E 3" - search_meta_discourse: "Cerca Meta Discourse" - javascript_evaluator: "Valuta JavaScript" - tool_description: - update_artifact: "Aggiornato un artefatto web utilizzando il bot IA" - web_browser: "Lettura %{url}" - github_search_files: "Hai cercato \"%{keywords}\" in %{repo}/%{branch}" - github_search_code: "Hai cercato \"%{query}\" in %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Contenuto recuperato di %{file_paths} da %{repo_name}@%{branch}" - random_picker: "Scelta da %{options}, scelto: %{result}" - read: "Lettura: %{title}" - time: "L'orario in %{timezone} è %{time}" - summarize: "Riassunto di %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "%{count} categoria trovata" - other: "%{count} categorie trovate" - tags: - one: "%{count} etichetta trovata" - other: "%{count} etichette trovate" - search: - one: "Trovato %{count} risultato per '%{query}'" - other: "Trovati %{count} risultati per '%{query}'" - search_meta_discourse: - one: "Trovato %{count} risultato per '%{query}'" - other: "Trovati %{count} risultati per '%{query}'" - google: - one: "Trovato %{count} risultato per '%{query}'" - other: "Trovati %{count} risultati per '%{query}'" - setting_context: "Contesto di lettura per: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "Trovato %{count} risultato per '%{query}'" - other: "Trovati %{count} risultati per '%{query}'" - summarization: - configuration_hint: - one: "Configura prima l'impostazione \"%{setting}\"." - other: "Configura prima queste impostazioni: %{settings}" - chat: - no_targets: "Non ci sono stati messaggi durante il periodo selezionato." - sentiment: - reports: - overall_sentiment: "Sentimento generale (positivo - negativo)" - post_emotion: - sadness: "Tristezza \U0001F622" - surprise: "Sorpresa \U0001F631" - neutral: "Neutro \U0001F610" - fear: "Paura \U0001F628" - anger: "Rabbia \U0001F621" - joy: "Gioia \U0001F600" - disgust: "Disgusto \U0001F922" - sentiment_analysis: - positive: "Positiva" - negative: "Negativa" - neutral: "Neutro" - llm: - configuration: - disable_module_first: "Devi prima disabilitare %{setting}." - set_llm_first: "Imposta prima %{setting}" - model_unreachable: "Non è stato possibile ottenere una risposta da questo modello. Controlla prima le tue impostazioni." - invalid_seeded_model: "Non è possibile utilizzare questo modello con questa funzionalità" - must_select_model: "Devi prima selezionare un LLM" - endpoints: - not_configured: "%{display_name} (non configurato)" - configuration_hint: - one: "Assicurati che l'impostazione `%{settings}` sia stata configurata." - other: "Assicurati che queste impostazioni siano state configurate: %{settings}" - delete_failed: - one: "Non è stato possibile eliminare questo modello perché è utilizzato da %{settings}. Aggiorna l'impostazione e riprova." - other: "Non è stato possibile eliminare questo modello perché lo stanno utilizzando %{settings}. Aggiorna le impostazioni e riprova." - cannot_edit_builtin: "Non è possibile modificare un modello incorporato." - embeddings: - delete_failed: "Questo modello è attualmente in uso. Aggiorna prima `ai embeddings selected model`." - cannot_edit_builtin: "Non è possibile modificare un modello incorporato." - configuration: - disable_embeddings: "Devi prima disabilitare \"integrazioni ia abilitate\"." - choose_model: "Imposta prima \"ai embeddings selected model\"." - llm_models: - missing_provider_param: "%{param} non può essere vuoto" - bedrock_invalid_url: "Compila tutti i campi per utilizzare questo modello." - ai_staff_action_logger: - updated: "aggiornato" - removed: "rimosso" - errors: - quota_exceeded: "Hai superato la quota per questo modello. Riprova tra %{relative_time}." - quota_required: "È necessario specificare il numero massimo di token o utilizzi per questo modello" - no_query_specified: Il parametro di query è obbligatorio, specificalo. - no_user_for_persona: Il personaggio specificato non ha alcun utente associato. - persona_not_found: Il personaggio specificato non esiste. Controlla i parametri persona_name o persona_id. - no_user_specified: Il parametro username o user_unique_id è obbligatorio, specificalo. - user_not_found: L'utente specificato non esiste. Controlla il parametro username. - persona_disabled: Il personaggio specificato è disattivato. Controlla i parametri persona_name o persona_id. - no_default_llm: Il personaggio deve avere un default_llm definito. - user_not_allowed: All'utente non è consentito partecipare all'argomento. - prompt_message_length: Il messaggio %{idx} supera il limite di 1000 caratteri. diff --git a/config/locales/server.ja.yml b/config/locales/server.ja.yml deleted file mode 100644 index 7096c4b0..00000000 --- a/config/locales/server.ja.yml +++ /dev/null @@ -1,434 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ja: - discourse_automation: - ai: - flag_types: - review: "レビューキューに投稿を追加" - spam: "迷惑として通報して投稿を非表示" - spam_silence: "迷惑として通報し、投稿を非表示にしてユーザーを投稿禁止にする" - scriptables: - llm_triage: - title: AI を使った投稿のトリアージ - description: "大規模言語モデルを使って投稿に優先順位を付けます" - flagged_post: | -
モデルからの応答:
-

%%LLM_RESPONSE%%

- %%AUTOMATION_NAME%% ルールによってトリガーされました。 - llm_report: - title: AI を使った定期レポート - description: "大規模言語モデルに基づく定期レポート" - site_settings: - discourse_ai_enabled: "Discourse AI プラグインを有効にします。" - ai_toxicity_enabled: "毒コメントモジュールを有効にします。" - ai_toxicity_inference_service_api_endpoint: "毒コメントモジュールの API が実行している URL" - ai_toxicity_inference_service_api_key: "毒コメント API の API キー" - ai_toxicity_inference_service_api_model: "推論に使用するモデル。多言語モデルは、イタリア語、フランス語、ロシア語、ポルトガル語、スペイン語、およびトルコ語で動作します。" - ai_toxicity_flag_automatically: "構成されたしきい値を超える投稿 / チャットメッセージを自動的に通報します。" - ai_toxicity_flag_threshold_toxicity: "毒コメント: ディスカッションから退出したり、自分の意見の共有を諦めたりさせられる可能性がある程度ある、失礼、無礼、または不合理なコメント" - ai_toxicity_flag_threshold_severe_toxicity: "深刻な毒コメント: ディスカッションから退出したり、自分の意見の共有を諦めたりさせられる可能性が高い、非常に憎しみに満ちた、攻撃的、または無礼なコメント" - ai_toxicity_flag_threshold_obscene: "わいせつ" - ai_toxicity_flag_threshold_identity_attack: "アイデンティティ攻撃" - ai_toxicity_flag_threshold_insult: "侮辱" - ai_toxicity_flag_threshold_threat: "脅迫" - ai_toxicity_flag_threshold_sexual_explicit: "露骨な性的表現" - ai_toxicity_groups_bypass: "このグループのユーザーの投稿は、毒コメントモジュールによって分類されません。" - ai_sentiment_enabled: "センチメントモジュールを有効にします。" - ai_sentiment_inference_service_api_endpoint: "センチメントモジュールの API が実行している URL" - ai_sentiment_inference_service_api_key: "センチメント API の API キー" - ai_sentiment_models: "推論に使用するモデル。センチメントは、投稿をポジティブ、ニュートラル、ネガティブの空間に分類します。感情は、怒り、嫌悪、恐怖、喜び、ニュートラル、悲しみ、驚きの空間に分類されます。" - ai_nsfw_detection_enabled: "NSFW モジュールを有効にします。" - ai_nsfw_inference_service_api_endpoint: "NSFW モジュールの API が実行している URL" - ai_nsfw_inference_service_api_key: "NSFW API の API キー" - ai_nsfw_flag_automatically: "構成されたしきい値を超える NSFW 投稿を自動的に通報します。" - ai_nsfw_flag_threshold_general: "NSFW と見なされる画像の一般しきい値。" - ai_nsfw_flag_threshold_drawings: "描画が NSFW と見なされるしきい値。" - ai_nsfw_flag_threshold_hentai: "エロアニメとして分類された画像が NSFW として見なされるためのしきい値。" - ai_nsfw_flag_threshold_porn: "ポルノとして分類された画像が NSFW として見なされるためのしきい値。" - ai_nsfw_flag_threshold_sexy: "官能的として分類された画像が NSFW として見なされるためのしきい値。" - ai_nsfw_models: "NSFW 推論に使用するモデル。" - ai_helper_enabled: "AI ヘルパーを有効にします。" - composer_ai_helper_allowed_groups: "これらのグループのユーザーの作成ツールには、AI ヘルパーボタンが表示されます。" - ai_helper_allowed_in_pm: "PM で作成ツールの AI ヘルパーを有効にします。" - ai_helper_model: "AI ヘルパーに使用するモデル。" - ai_helper_custom_prompts_allowed_groups: "これらのグループのユーザーには、AI ヘルパーにカスタムプロンプトオプションが表示されます。" - ai_helper_automatic_chat_thread_title_delay: "AI ヘルパーがチャットスレッドのタイトルを自動的に設定するまでの遅延 (分単位)。" - ai_helper_automatic_chat_thread_title: "スレッドのコンテンツに基づいてチャットスレッドのタイトルを自動的に設定する。" - ai_helper_illustrate_post_model: "作成ツールの AI ヘルパーのイラスト投稿機能に使用するモデル" - ai_helper_enabled_features: "AI ヘルパーで有効にする機能を選択します。" - post_ai_helper_allowed_groups: "投稿内で AI ヘルパー機能にアクセスできるユーザーグループ" - ai_helper_image_caption_model: "画像キャプションの生成に使用するモデルを選択します" - ai_auto_image_caption_allowed_groups: "これらのグループのユーザーは画像の自動キャプション作成を切り替えられます。" - ai_embeddings_selected_model: "選択したモデルを使用して、埋め込みを生成します。" - ai_embeddings_generate_for_pms: "個人メッセージの埋め込みを生成します。" - ai_embeddings_semantic_related_topics_enabled: "関連トピックのセマンティック検索を使用します。" - ai_embeddings_semantic_related_topics: "関連トピックセクションに表示する最大トピック数。" - ai_embeddings_backfill_batch_size: "15 分ごとにバックフィルする埋め込みの数。" - ai_embeddings_semantic_search_enabled: "全ページのセマンティック検索を有効にします。" - ai_embeddings_semantic_quick_search_enabled: "検索メニューポップアップのセマンティック検索オプションを有効にします。" - ai_embeddings_semantic_related_include_closed_topics: "閉鎖トピックをセマンティック検索結果に含める" - ai_embeddings_semantic_search_hyde_model: "セマンティック検索中に得られる結果を改善するためにキーワードの拡張に使用されるモデル" - ai_embeddings_per_post_enabled: 各投稿の埋め込みを生成する - ai_custom_summarization_allowed_groups: "新しい要約の作成に使用できるグループ。" - ai_pm_summarization_allowed_groups: "PM での要約の作成と表示が許可されたグループ。" - ai_summary_gists_allowed_groups: "ホットなトピックリストで gist の表示が許可されたグループ。" - ai_summary_backfill_maximum_topics_per_hour: "1 時間あたりにバックフィルするトピック要約の数。" - ai_bot_enabled: "AI ボットモジュールを有効にします。" - ai_bot_enable_chat_warning: "PM チャットが開始されたら警告を表示する。翻訳文字列の discourse_ai.ai_bot.pm_warning を編集して上書き可能" - ai_bot_allowed_groups: "GPT ボットが PM にアクセスできる場合、これらのグループのメンバーに返信します。" - ai_bot_debugging_allowed_groups: "これらのグループが生の AI リクエストと応答を表示するデバッグボタンを投稿に表示することを許可する" - ai_bot_public_sharing_allowed_groups: "これらのグループが、一意の公開リンクを介して AI 個人メッセージを公開共有することを許可します。注意: サイトにログインが必要な場合、共有にもログインが必要です。" - ai_bot_add_to_header: "AI ボットとの PM を開始するためのボタンをヘッダーに表示する" - ai_bot_github_access_token: "GitHub AI ツールで使用する GitHub アクセストークン (検索サポートに必要)" - ai_stability_api_key: "stability.ai API の API キー" - ai_stability_engine: "stability.ai API に使用する画像生成エンジン" - ai_stability_api_url: "stability.ai API の URL" - ai_google_custom_search_api_key: "Google カスタム検索 API の API キーは https://developers.google.com/custom-search を参照してください" - ai_google_custom_search_cx: "Google カスタム検索 API の CX" - reviewables: - reasons: - flagged_by_toxicity: AI プラグインは、これを毒コメントとして分類した後に通報しました。 - flagged_by_nsfw: AI プラグインは、添付画像の少なくとも 1 つを NSFW として分類した後に通報しました。 - reports: - overall_sentiment: - title: "全体的なセンチメント" - description: 'チャートは肯定的または否定的のいずれかに分類された投稿の数を比較しています。これらは、肯定的または否定的なスコアが既定のしきい値スコアを超えた場合に計算されます。つまり、中立的な投稿は表示されません。個人メッセージ (PM) も除外されます。"cardiffnlp/twitter-roberta-base-sentiment-latest" で分類されています。' - xaxis: "ポジティブ(%)" - yaxis: "日付" - emotion_admiration: - title: "\U0001F929 賞賛" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「賞賛」の感情で分類された投稿。" - emotion_amusement: - title: "\U0001F604 楽しい" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「楽しい」の感情で分類された投稿。" - emotion_anger: - title: "\U0001F620 怒り" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「怒り」の感情で分類された投稿。" - emotion_annoyance: - title: "\U0001F612 煩わしい" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「煩わしい」の感情で分類された投稿。" - emotion_approval: - title: "\U0001F44D 承認" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「承認」の感情で分類された投稿。" - emotion_caring: - title: "\U0001F917 思いやり" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「思いやり」の感情で分類された投稿。" - emotion_confusion: - title: "\U0001F615 混乱" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「混乱」の感情で分類された投稿。" - emotion_curiosity: - title: "\U0001F914 好奇心" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「好奇心」の感情で分類された投稿。" - emotion_desire: - title: "\U0001F60D 欲望" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「欲望」の感情で分類された投稿。" - emotion_disappointment: - title: "\U0001F61E 失望" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「失望」の感情で分類された投稿。" - emotion_disapproval: - title: "\U0001F44E 却下" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「却下」の感情で分類された投稿。" - emotion_disgust: - title: "\U0001F922 嫌悪" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「嫌悪」の感情で分類された投稿。" - emotion_embarrassment: - title: "\U0001F633 恥ずかしい" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「恥ずかしい」の感情で分類された投稿。" - emotion_excitement: - title: "\U0001F92A 興奮" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「興奮」の感情で分類された投稿。" - emotion_fear: - title: "\U0001F628 恐怖" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「恐怖」の感情で分類された投稿。" - emotion_gratitude: - title: "\U0001F64F 感謝" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「感謝」の感情で分類された投稿。" - emotion_grief: - title: "\U0001F622 悲嘆" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「悲嘆」の感情で分類された投稿。" - emotion_joy: - title: "\U0001F60A 喜び" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「喜び」の感情で分類された投稿。" - emotion_love: - title: '❤️ 愛' - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「愛」の感情で分類された投稿。" - emotion_nervousness: - title: "\U0001F630 緊張" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「緊張」の感情で分類された投稿。" - emotion_neutral: - title: "\U0001F610 ニュートラル" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「ニュートラル」の感情で分類された投稿。" - emotion_optimism: - title: "\U0001F31F 楽観的" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「楽観的」の感情で分類された投稿。" - emotion_pride: - title: "\U0001F981 プライド" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「プライド」の感情で分類された投稿。" - emotion_realization: - title: "\U0001F4A1 思いつき" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「思いつき」の感情で分類された投稿。" - emotion_relief: - title: "\U0001F60C 安心" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「安心」の感情で分類された投稿。" - emotion_remorse: - title: "\U0001F614 後悔" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「後悔」の感情で分類された投稿。" - emotion_sadness: - title: "\U0001F62D 悲しみ" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「悲しみ」の感情で分類された投稿。" - emotion_surprise: - title: "\U0001F632 驚き" - description: "SamLowe/roberta-base-go_emotions' モデルを使って AI によって「驚き」の感情で分類された投稿。" - discourse_ai: - ai_artifact: - view_source: "ソースを表示" - view_changes: "変更を表示" - unknown_model: "不明な AI モデル" - tools: - custom_name: "%{name} (カスタム)" - presets: - browse_web_jina: - name: "ウェブを閲覧 (jina.ai)" - exchange_rate: - name: "為替レート" - stock_quote: - name: "下部か (AlphaVantage)" - image_generation: - name: "Flux 画像ジェネレーター (Together.ai)" - empty_tool: - name: "空白から始める..." - ai_helper: - errors: - completion_request_failed: "提案しようとしたときに問題が発生しました。もう一度お試しください。" - prompts: - translate: '%{language} に翻訳する' - generate_titles: トピックのタイトルを提案 - proofread: テキストを構成 - markdown_table: Markdown テーブルを生成 - custom_prompt: "カスタムプロンプト" - explain: "説明" - illustrate_post: "イラスト投稿" - replace_dates: "スマート日付" - painter: - attribution: - stable_diffusion_xl: "Stable Diffusion XL による画像" - dall_e_3: "DALL-E 3 による画像" - image_caption: - attribution: "AI によるキャプション" - share_ai: - read_more: "トランスクリプト全文を読む" - onebox_title: "%{llm_name} で AI 会話" - formatted_excerpt: "%{llm_name} で AI 会話:\n %{excerpt}" - title: "%{title} - AI 会話 - %{site_name}" - errors: - not_allowed: "このトピックを共有できません" - other_people_in_pm: "他の人との個人メッセージは公開共有できません" - other_content_in_pm: "他の人の投稿を含む個人メッセージは公開共有できません" - failed_to_share: "会話を共有できませんでした" - conversation_deleted: "会話の共有が削除されました" - spam_detection: - flag_reason: "Discourse AI によって迷惑として通報されました" - silence_reason: "ユーザーは Discourse AI によって自動的に投稿禁止になりました" - invalid_error_type: "無効なエラータイプが指定されました" - unexpected: "予期しないエラーが発生しました" - bot_user_update_failed: "迷惑スキャンボットユーザーの更新に失敗しました" - ai_bot: - reply_error: "返信中に予期しない問題が発生したようです。\n\n\n[details='エラーの詳細']\n%{details}\n[/details]" - default_pm_prefix: "[無題の AI ボット PM]" - personas: - default_llm_required: "チャットを有効にする前に、デフォルトの LLM モデルが必要です" - cannot_delete_system_persona: "システムペルソナは削除できません。代わりに無効にしてください" - cannot_edit_system_persona: "システムペルソナは名前変更のみ可能です。ツールまたはシステムプロンプトは編集できません。代わりに無効にするかコピーを作成してください" - github_helper: - name: "GitHub ヘルパー" - description: "GitHub 関連タスクと質問の支援を専門とする AI ボット" - general: - name: フォーラムヘルパー - description: "さまざまなタスクを実行できる汎用 AI ボット" - artist: - name: アーティスト - description: "画像の生成を専門とする AI ボット" - sql_helper: - name: SQL ヘルパー - description: "この Discourse インスタンスでの SQL クエリの作成支援を専門とする AI ボット" - settings_explorer: - name: 設定エクスプローラー - description: "Discourse のサイト設定のナビ支援を専門とする AI ボット" - creative: - name: クリエイティブ - description: "外部統合のないクリエイティブ系タスクを専門とする AI ボット" - dall_e3: - name: "DALL-E 3" - description: "DALL-E 3 を使って画像の生成を専門とする AI ボット" - discourse_helper: - name: "Discourse ヘルパー" - description: "Discourse 関連タスクの支援を専門とする AI ボット" - web_artifact_creator: - name: "ウェブアーティファクトクリエーター" - description: "インタラクティブなウェブアーティファクトの作成に特化した AI ボット" - custom_prompt: - name: "カスタムプロンプト" - smart_dates: - name: "スマート日付" - topic_not_found: "要約がありません。トピックが見つかりません!" - summarizing: "トピックの要約を生成中" - searching: "検索中: '%{query}'" - tool_options: - researcher: - max_results: - name: "結果の最大件数" - google: - base_query: - name: "ベース検索クエリ" - description: "検索時に使用する基本クエリ。例: 「site:example.com」には example.com からの結果のみが含まれ、「before:2022-01-01」には 2021 年以前の結果のみが含まれます。このテキストは検索クエリの先頭に追加されます。" - read: - read_private: - name: "非公開を読む" - description: "ユーザーがアクセスできるすべてのトピックへのアクセスを許可します (デフォルトでは公開トピックのみが含まれます)" - search: - search_private: - name: "非公開を検索" - description: "検索結果にユーザーがアクセスできるすべてのトピックを含めます (デフォルトでは公開トピックのみが含まれます)" - max_results: - name: "結果の最大件数" - description: "検索に含める結果の最大件数 - 空白の場合はデフォルトのルールが使用され、件数は使用されるモデルに応じて調整されます。最高値は 100 です。" - base_query: - name: "ベース検索クエリ" - description: "検索時に使用するベースクエリ。例: '#urgent' は検索クエリの先頭に '#urgent' を追加し、緊急のカテゴリまたはタグを持つトピックのみが含まれます。" - tool_summary: - update_artifact: "ウェブアーティファクトの更新" - create_artifact: "ウェブアーティファクトを作成します" - web_browser: "ウェブを閲覧" - github_search_files: "GitHub 検索ファイル" - github_search_code: "GitHub コード検索" - github_file_content: "GitHub ファイルコンテンツ" - github_pull_request_diff: "GitHub プルリクエストの差分" - random_picker: "ランダムピッカー" - categories: "カテゴリをリスト表示" - search: "検索" - tags: "タグをリスト" - time: "時刻" - summarize: "要約" - image: "画像を生成" - google: "Google で検索" - read: "トピックを読む" - setting_context: "サイト設定のコンテキストを検索します" - schema: "データベーススキーマを検索します" - search_settings: "サイト設定を検索中" - dall_e: "画像を生成" - search_meta_discourse: "Discrouse Meta を検索" - javascript_evaluator: "JavaScript を評価" - tool_help: - update_artifact: "AI ボットを使ってウェブアーティファクトを更新します" - create_artifact: "AI ボットを使用してウェブアーティファクトを作成します" - web_browser: "AI ボットを使ってウェブページを閲覧する" - github_search_code: "GitHub リポジトリでコードを検索する" - github_search_files: "GitHub リポジトリでファイルを検索する" - github_file_content: "GitHub リポジトリからファイルのコンテンツを取得する" - github_pull_request_diff: "GitHub プルリクエストの差分を取得する" - random_picker: "ランダムな数字またはリストのランダムな要素を選択する" - categories: "フォーラムのすべての公開カテゴリをリストします" - search: "フォーラムのすべての公開トピックを検索します" - tags: "フォーラムのすべてのタグをリストします" - time: "様々なタイムゾーンの時間を調べます" - summary: "トピックを要約します" - image: "Stable Diffusion を使って画像を生成します" - google: "Googleでクエリを検索します" - read: "フォーラムの公開トピックを読み取ります" - setting_context: "サイト設定のコンテキストを検索します" - schema: "データベーススキーマを検索します" - search_settings: "サイト設定を検索します" - dall_e: "DALL-E 3 を使って画像を生成します" - search_meta_discourse: "Discrouse Meta を検索" - javascript_evaluator: "JavaScript を評価" - tool_description: - update_artifact: "AI ボットを使ってウェブアーティファクトを更新しました" - web_browser: "読み取り中: %{url}" - github_search_files: "%{repo}/%{branch} で「%{keywords}」を検索しました" - github_search_code: "%{repo} で「%{query}」を検索しました" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "%{repo_name}@%{branch} から %{file_paths} のコンテンツを取得しました" - random_picker: "%{options} から選択。選択: %{result}" - read: "読み取り中: %{title}" - time: "%{timezone} の時刻は %{time} です" - summarize: "%{title} の要約" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - other: "%{count} 個のカテゴリが見つかりました" - tags: - other: "%{count} 個のタグが見つかりました" - search: - other: "「%{query}」の結果が %{count} 件見つかりました" - search_meta_discourse: - other: "「%{query}」の結果が %{count} 件見つかりました" - google: - other: "「%{query}」の結果が %{count} 件見つかりました" - setting_context: "コンテキストを読み取り中: %{setting_name}" - schema: "%{tables}" - search_settings: - other: "「%{query}」の結果が %{count} 件見つかりました" - summarization: - configuration_hint: - other: "先に次の設定を構成します: %{settings}" - chat: - no_targets: "選択された期間中にメッセージはありませんでした。" - sentiment: - reports: - overall_sentiment: "全体的なセンチメント (肯定的 - 否定的)" - post_emotion: - sadness: "悲しみ \U0001F622" - surprise: "驚き \U0001F631" - neutral: "ニュートラル \U0001F610" - fear: "恐怖 \U0001F628" - anger: "怒り \U0001F621" - joy: "喜び \U0001F600" - disgust: "嫌悪 \U0001F922" - sentiment_analysis: - positive: "ポジティブ" - negative: "ネガティブ" - neutral: "ニュートラル" - llm: - configuration: - disable_module_first: "まず %{setting} を無効にする必要があります" - set_llm_first: "先に %{setting} を設定します" - model_unreachable: "このモデルから応答を得られませんでした。まず設定を確認してください。" - invalid_seeded_model: "この機能ではこのモデルを使用できません" - must_select_model: "先に LLM を選択する必要があります" - endpoints: - not_configured: "%{display_name} (未構成)" - configuration_hint: - other: "次の設定が構成されていることを確認してください: %{settings}" - delete_failed: - other: "%{settings} が使用中であるため、このモデルを削除できませんでした。設定を更新してもう一度お試しください。" - cannot_edit_builtin: "組み込みのモデルを編集することはできません。" - embeddings: - delete_failed: "このモデルは使用中です。先に `ai embeddings selected model` を更新してください。" - cannot_edit_builtin: "組み込みのモデルを編集することはできません。" - configuration: - disable_embeddings: "まず「AI 埋め込み対応」を無効にしてください。" - choose_model: "先に「AI 埋め込みに選択されたモデル」を設定してください。" - llm_models: - missing_provider_param: "%{param} は空白にできません" - bedrock_invalid_url: "このモデルを使用するには、すべてのフィールドに入力してください。" - ai_staff_action_logger: - updated: "更新" - removed: "削除" - errors: - quota_exceeded: "このモデルの使用量制限を超過しました。%{relative_time}後にもう一度お試しください。" - quota_required: "このモデルのトークンまたは使用量の上限を指定する必要があります" - no_query_specified: クエリパラメーターは必須です。指定してください。 - no_user_for_persona: 指定されたペルソナにはユーザーが関連付けられていません。 - persona_not_found: 指定されたペルソナは存在しません。persona_name または persona_id パラメーターを確認してください。 - no_user_specified: username または user_unique_id パラメーターは必須です。指定してください。 - user_not_found: 指定されたユーザーは存在しません。username パラメーターを確認してください。 - persona_disabled: 指定されたペルソナは無効化されています。persona_name または persona_id パラメーターを確認してください。 - no_default_llm: ペルソナには default_llm が定義されている必要があります。 - user_not_allowed: ユーザーはトピックに参加できません。 - prompt_message_length: メッセージ %{idx} は 1000 文字の上限を超えています。 diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml deleted file mode 100644 index 0c984524..00000000 --- a/config/locales/server.ko.yml +++ /dev/null @@ -1,27 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ko: - reports: - overall_sentiment: - yaxis: "날짜" - emotion_neutral: - title: "\U0001F610 중립국" - discourse_ai: - ai_bot: - tool_summary: - search: "검색" - time: "시간" - summarize: "요약하기" - sentiment: - reports: - post_emotion: - neutral: "중립국 \U0001F610" - sentiment_analysis: - neutral: "중립국" - ai_staff_action_logger: - updated: "업데이트" - removed: "제거됨" diff --git a/config/locales/server.lt.yml b/config/locales/server.lt.yml deleted file mode 100644 index b903a554..00000000 --- a/config/locales/server.lt.yml +++ /dev/null @@ -1,32 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -lt: - site_settings: - ai_helper_image_caption_model: "Pasirinkite modelį, kurį naudosite vaizdų antraštėms generuoti" - reports: - overall_sentiment: - yaxis: "Data" - emotion_neutral: - title: "\U0001F610 Neutrali" - discourse_ai: - ai_helper: - image_caption: - attribution: "Antraštė teikiama AI" - ai_bot: - tool_summary: - search: "Paieška" - time: "Laikas" - summarize: "Apibendrinti" - sentiment: - reports: - post_emotion: - neutral: "Neutrali \U0001F610" - sentiment_analysis: - neutral: "Neutrali" - ai_staff_action_logger: - updated: "atnaujinta" - removed: "pašalintas" diff --git a/config/locales/server.lv.yml b/config/locales/server.lv.yml deleted file mode 100644 index 817cfcab..00000000 --- a/config/locales/server.lv.yml +++ /dev/null @@ -1,17 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -lv: - reports: - overall_sentiment: - yaxis: "Datums" - discourse_ai: - ai_bot: - tool_summary: - search: "Meklēt" - time: "Laiks" - ai_staff_action_logger: - removed: "noņemts" diff --git a/config/locales/server.nb_NO.yml b/config/locales/server.nb_NO.yml deleted file mode 100644 index 3a52f247..00000000 --- a/config/locales/server.nb_NO.yml +++ /dev/null @@ -1,18 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -nb_NO: - reports: - overall_sentiment: - yaxis: "Dato" - discourse_ai: - ai_bot: - tool_summary: - search: "Søk" - time: "Tid" - ai_staff_action_logger: - updated: "oppdatert" - removed: "fjernet" diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml deleted file mode 100644 index 63e200ce..00000000 --- a/config/locales/server.nl.yml +++ /dev/null @@ -1,445 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -nl: - discourse_automation: - ai: - flag_types: - review: "Bericht toevoegen aan beoordelingswachtrij" - spam: "Als spam markeren en bericht verbergen" - spam_silence: "Als spam markeren, bericht verbergen en gebruiker dempen" - scriptables: - llm_triage: - title: Berichten triageren met AI - description: "Triageer berichten met behulp van een groot taalmodel" - flagged_post: | -
Reactie van het model:
-

%%LLM_RESPONSE%%

- Getriggerd door de regel %%AUTOMATION_NAME%%. - llm_report: - title: Periodiek rapport met AI - description: "Periodiek rapport op basis van een groot taalmodel" - site_settings: - discourse_ai_enabled: "Schakel de Discours-AI-plug-in in." - ai_toxicity_enabled: "Schakel de toxiciteitsmodule in." - ai_toxicity_inference_service_api_endpoint: "URL waar de API wordt uitgevoerd voor de toxiciteitsmodule" - ai_toxicity_inference_service_api_key: "API-sleutel voor de toxiciteits-API" - ai_toxicity_inference_service_api_model: "Te gebruiken model voor inferentie. Het meertalige model werkt met Italiaans, Frans, Russisch, Portugees, Spaans en Turks." - ai_toxicity_flag_automatically: "Markeer automatisch berichten/chatberichten die boven de geconfigureerde drempels komen." - ai_toxicity_flag_threshold_toxicity: "Toxiciteit: een onbeschofte, respectloze of onredelijke opmerking die er enigszins waarschijnlijk toe leidt dat je een discussie verlaat of stopt met het delen van je mening" - ai_toxicity_flag_threshold_severe_toxicity: "Ernstige toxiciteit: een zeer hatelijke, agressieve of respectloze opmerking die er zeer waarschijnlijk toe leidt dat je een discussie verlaat of stopt met het delen van je mening" - ai_toxicity_flag_threshold_obscene: "Obsceen" - ai_toxicity_flag_threshold_identity_attack: "Aanval op identiteit" - ai_toxicity_flag_threshold_insult: "Belediging" - ai_toxicity_flag_threshold_threat: "Bedreiging" - ai_toxicity_flag_threshold_sexual_explicit: "Seksueel expliciet" - ai_toxicity_groups_bypass: "De berichten van gebruikers in die groepen worden niet geclassificeerd door de toxiciteitsmodule." - ai_sentiment_enabled: "Schakel de sentimentmodule in." - ai_sentiment_inference_service_api_endpoint: "URL waar de API wordt uitgevoerd voor de sentimentmodule" - ai_sentiment_inference_service_api_key: "API-sleutel voor de sentiment-API" - ai_sentiment_models: "Te gebruiken model voor inferentie. Sentiment classificeert berichten in de ruimte positief/neutraal/negatief. Emotie classificeert berichten in de ruimte boosheid/afschuw/angst/vreugde/neutraal/verdriet/verrassing." - ai_nsfw_detection_enabled: "Schakel de NVWW-module in." - ai_nsfw_inference_service_api_endpoint: "URL waar de API wordt uitgevoerd voor de NVWW-module" - ai_nsfw_inference_service_api_key: "API-sleutel voor de NVWW-API" - ai_nsfw_flag_automatically: "Markeer automatisch NVWW-berichten die boven de geconfigureerde drempels komen." - ai_nsfw_flag_threshold_general: "Algemene drempel voor een afbeelding om als NSFW te worden beschouwd." - ai_nsfw_flag_threshold_drawings: "Drempel voor een tekening om als NSFW te worden beschouwd." - ai_nsfw_flag_threshold_hentai: "Drempelwaarde voor een als hentai geclassificeerde afbeelding om als NSFW te worden beschouwd." - ai_nsfw_flag_threshold_porn: "Drempelwaarde voor een als porno geclassificeerde afbeelding om als NSFW te worden beschouwd." - ai_nsfw_flag_threshold_sexy: "Drempelwaarde voor een als sexy geclassificeerde afbeelding om als NSFW te worden beschouwd." - ai_nsfw_models: "Te gebruiken modellen voor NVWW-inferentie." - ai_helper_enabled: "Schakel de AI-helper in." - composer_ai_helper_allowed_groups: "Gebruikers in deze groepen zien de AI-hulpknop in de editor." - ai_helper_allowed_in_pm: "Schakel de AI-hulp van de editor in in PB's." - ai_helper_model: "Te gebruiken model voor de AI-hulp." - ai_helper_custom_prompts_allowed_groups: "Gebruikers in deze groepen zien de optie voor een aangepaste prompt in de AI-helper." - ai_helper_automatic_chat_thread_title_delay: "Vertraging in minuten voordat de AI-helper automatisch de titel van de chatthread instelt." - ai_helper_automatic_chat_thread_title: "Stel automatisch de titel van chatthreads in op basis van de threadinhoud." - ai_helper_illustrate_post_model: "Te gebruiken model voor de berichtillustratiefunctie van de opstellings-AI-helper" - ai_helper_enabled_features: "Selecteer de functies die je wilt inschakelen in de AI-helper." - post_ai_helper_allowed_groups: "Gebruikersgroepen die toegang hebben tot AI-helperfuncties in berichten" - ai_helper_image_caption_model: "Selecteer het te gebruiken model voor het genereren van afbeeldingsbijschriften" - ai_auto_image_caption_allowed_groups: "Gebruikers in deze groepen kunnen automatische afbeeldingsbijschriften in- en uitschakelen." - ai_embeddings_selected_model: "Gebruik het geselecteerde model voor het genereren van insluitingen." - ai_embeddings_generate_for_pms: "Genereer insluitingen voor persoonlijke berichten." - ai_embeddings_semantic_related_topics_enabled: "Gebruik semantisch zoeken voor gerelateerde topics." - ai_embeddings_semantic_related_topics: "Maximaal aantal topics om weer te geven in de sectie met gerelateerde topics." - ai_embeddings_backfill_batch_size: "Aantal insluitingen dat elke 15 minuten moet worden aangevuld." - ai_embeddings_semantic_search_enabled: "Schakel semantisch zoeken op volledige pagina's in." - ai_embeddings_semantic_quick_search_enabled: "Schakel de semantische zoekoptie in de zoekmenupop-up in." - ai_embeddings_semantic_related_include_closed_topics: "Neem gesloten topics op in semantische zoekresultaten" - ai_embeddings_semantic_search_hyde_model: "Gebruikt model voor het uitbreiden van trefwoorden om betere resultaten te krijgen bij semantisch zoeken" - ai_embeddings_per_post_enabled: Insluitingen genereren voor elk bericht - ai_summarization_model: "Te gebruiken model voor samenvattingen" - ai_custom_summarization_allowed_groups: "Groepen die nieuwe samenvattingen mogen maken." - ai_pm_summarization_allowed_groups: "Groepen mogen samenvattingen maken en bekijken in PB's." - ai_summary_gists_enabled: "Genereer automatisch korte samenvattingen van de nieuwste reacties in topics" - ai_summary_gists_allowed_groups: "Groepen kunnen samenvattingen zien in de lijst van populaire topics." - ai_summary_backfill_maximum_topics_per_hour: "Aantal topicsamenvattingen om aan te vullen per uur." - ai_bot_enabled: "Schakel de AI-botmodule in." - ai_bot_enable_chat_warning: "Geef een waarschuwing weer wanneer een privéchat wordt gestart. Kan worden genegeerd door de vertaling te bewerken: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Wanneer de GPT-bot toegang heeft tot het privébericht, zal deze reageren op leden van deze groepen." - ai_bot_debugging_allowed_groups: "Geef deze groepen een debugknop bij berichten die het ruwe AI-verzoek en -antwoord weergeeft" - ai_bot_public_sharing_allowed_groups: "Sta deze groepen toe persoonlijke AI-berichten met het publiek delen via een unieke, openbaar beschikbare link. Let op: als aanmelden vereist is op je site, is dat ook vereist voor delen." - ai_bot_add_to_header: "Geef een knop weer in de kop om een privéchat te starten met een AI-bot" - ai_bot_github_access_token: "GitHub-toegangstoken voor gebruik met GitHub AI-tools (vereist voor zoekondersteuning)" - ai_stability_api_key: "API-sleutel voor de stability.ai-API" - ai_stability_engine: "Te gebruiken engine voor het genereren van afbeeldingen voor de stability.ai-API" - ai_stability_api_url: "URL voor de stability.ai-API" - ai_google_custom_search_api_key: "API-sleutel voor de Google Custom Search-API zie: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX voor Google Custom Search-API" - reviewables: - reasons: - flagged_by_toxicity: De AI-plug-in heeft dit gemarkeerd nadat het als toxisch was geclassificeerd. - flagged_by_nsfw: De AI-plug-in heeft dit gemarkeerd na classificatie van ten minste één van de bijgevoegde afbeeldingen als NVWW. - reports: - overall_sentiment: - title: "Algemeen sentiment" - description: 'De grafiek vergelijkt het aantal berichten dat als positief of negatief is geclassificeerd. Deze worden berekend wanneer positieve of negatieve scores groter zijn dan de ingestelde drempelscore. Dit betekent dat neutrale berichten niet worden weergegeven. Persoonlijke berichten (PB''s) zijn ook uitgesloten. Geclassificeerd met "cardiffnlp/twitter-roberta-base-sentiment-latest".' - xaxis: "Positief (%)" - yaxis: "Datum" - emotion_admiration: - title: "\U0001F929 Bewondering" - description: "Berichten geclassificeerd met de emotie bewondering via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_amusement: - title: "\U0001F604 Geamuseerd" - description: "Berichten geclassificeerd met de emotie geamuseerd via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_anger: - title: "\U0001F620 Boosheid" - description: "Berichten geclassificeerd met de emotie woede via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_annoyance: - title: "\U0001F612 Ergernis" - description: "Berichten geclassificeerd met de emotie ergernis via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_approval: - title: "\U0001F44D Goedkeuring" - description: "Berichten geclassificeerd met de emotie goedkeuring via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_caring: - title: "\U0001F917 Zorgzaam" - description: "Berichten geclassificeerd met de emotie zorgzaam via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_confusion: - title: "\U0001F615 Verwarring" - description: "Berichten geclassificeerd met de emotie verwarring via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_curiosity: - title: "\U0001F914 Nieuwsgierigheid" - description: "Berichten geclassificeerd met de emotie nieuwsgierigheid via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_desire: - title: "\U0001F60D Verlangen" - description: "Berichten geclassificeerd met de emotie verlangen via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_disappointment: - title: "\U0001F61E Teleurstelling" - description: "Berichten geclassificeerd met de emotie teleurstelling via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_disapproval: - title: "\U0001F44E Afkeuring" - description: "Berichten geclassificeerd met de emotie afkeuring via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_disgust: - title: "\U0001F922 Afschuw" - description: "Berichten geclassificeerd met de emotie afschuw via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_embarrassment: - title: "\U0001F633 Schaamte" - description: "Berichten geclassificeerd met de emotie schaamte via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_excitement: - title: "\U0001F92A Opwinding" - description: "Berichten geclassificeerd met de emotie opwinding via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_fear: - title: "\U0001F628 Angst" - description: "Berichten geclassificeerd met de emotie angst via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_gratitude: - title: "\U0001F64F Dankbaarheid" - description: "Berichten geclassificeerd met de emotie dankbaarheid via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_grief: - title: "\U0001F622 Verdriet" - description: "Berichten geclassificeerd met de emotie verdriet via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_joy: - title: "\U0001F60A Vreugde" - description: "Berichten geclassificeerd met de emotie vreugde via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_love: - title: '❤️ Liefde' - description: "Berichten geclassificeerd met de emotie liefde via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_nervousness: - title: "\U0001F630 Nervositeit" - description: "Berichten geclassificeerd met de emotie nervositeit via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_neutral: - title: "\U0001F610 Neutraal" - description: "Berichten geclassificeerd met de emotie neutraal via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_optimism: - title: "\U0001F31F Optimisme" - description: "Berichten geclassificeerd met de emotie optimisme via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_pride: - title: "\U0001F981 Trots" - description: "Berichten geclassificeerd met de emotie trots via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_realization: - title: "\U0001F4A1 Besef" - description: "Berichten geclassificeerd met de emotie besef via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_relief: - title: "\U0001F60C Opluchting" - description: "Berichten geclassificeerd met de emotie oplichting via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_remorse: - title: "\U0001F614 Spijt" - description: "Berichten geclassificeerd met de emotie spijt via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_sadness: - title: "\U0001F62D Droefheid" - description: "Berichten geclassificeerd met de emotie droefheid via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - emotion_surprise: - title: "\U0001F632 Verrassing" - description: "Berichten geclassificeerd met de emotie verrassing via AI, met behulp van het model 'SamLowe/roberta-base-go_emotions'." - discourse_ai: - ai_artifact: - view_source: "Bron weergeven" - view_changes: "Wijzigingen weergeven" - unknown_model: "Onbekend AI-model" - tools: - custom_name: "%{name} (aangepast)" - presets: - browse_web_jina: - name: "Web surfen (jina.ai)" - exchange_rate: - name: "Wisselkoers" - stock_quote: - name: "Aandelenkoers (AlphaVantage)" - image_generation: - name: "Flux-afbeeldingsgenerator (Together.ai)" - empty_tool: - name: "Vanaf blanco beginnen..." - ai_helper: - errors: - completion_request_failed: "Er is iets misgegaan bij het geven van suggesties. Probeer het opnieuw." - prompts: - translate: Vertalen naar %{language} - generate_titles: Topictitels voorstellen - proofread: Tekst proeflezen - markdown_table: Markdowntabel genereren - custom_prompt: "Aangepaste prompt" - explain: "Uitleggen" - illustrate_post: "Bericht illustreren" - replace_dates: "Slimme datums" - painter: - attribution: - stable_diffusion_xl: "Afbeelding door Stable Diffusion XL" - dall_e_3: "Afbeelding door DALL-E 3" - image_caption: - attribution: "Bijschrift door AI" - share_ai: - read_more: "Volledig transcript lezen" - onebox_title: "AI-conversatie met %{llm_name}" - formatted_excerpt: "AI-conversatie met %{llm_name}:\n%{excerpt}" - title: "%{title} - AI-conversatie - %{site_name}" - errors: - not_allowed: "Je mag dit topic niet delen" - other_people_in_pm: "Persoonlijke berichten met andere mensen kunnen niet openbaar worden gedeeld" - other_content_in_pm: "Persoonlijke berichten met berichten van andere mensen kunnen niet openbaar worden gedeeld" - failed_to_share: "Delen van de conversatie mislukt" - conversation_deleted: "Conversatiedeling verwijderd" - spam_detection: - flag_reason: "Gemarkeerd als spam door Discourse-AI" - silence_reason: "Gebruiker automatisch gedempt door Discourse-AI" - invalid_error_type: "Ongeldig fouttype opgegeven" - unexpected: "Er is een onverwachte fout opgetreden" - bot_user_update_failed: "Bijwerken van gebruiker voor spamscanbot mislukt" - ai_bot: - reply_error: "Het lijkt erop dat ons systeem een onverwacht probleem heeft ondervonden terwijl het probeerde te antwoorden.\n\n[details='Error details']\n%{details}\n[/details]" - default_pm_prefix: "[Ongetitelde PB van AI-bot]" - personas: - default_llm_required: "Standaard LLM-model is vereist voordat Chat wordt ingeschakeld" - cannot_delete_system_persona: "Systeempersona's kunnen niet worden verwijderd. Schakel deze in plaats daarvan uit" - cannot_edit_system_persona: "Systeempersona's kunnen alleen worden hernoemd. Je kunt tools of systeemprompts niet bewerken. Je kunt ze wel uitschakelen en een kopie maken" - github_helper: - name: "GitHub Helper" - description: "AI-bot gespecialiseerd in het assisteren bij GitHub-gerelateerde taken en vragen" - general: - name: Forumhelper - description: "AI-bot voor algemeen gebruik die verschillende taken kan uitvoeren" - artist: - name: Artiest - description: "AI-bot gespecialiseerd in het genereren van afbeeldingen" - sql_helper: - name: SQL-helper - description: "AI-bot gespecialiseerd in het helpen opstellen van SQL-query's in dit Discourse-exemplaar" - settings_explorer: - name: Instellingenverkenner - description: "AI-bot gespecialiseerd in het verkennen van Discourse-site-instellingen" - creative: - name: Creatief - description: "AI-bot zonder externe integraties, gespecialiseerd in creatieve taken" - dall_e3: - name: "DALL-E 3" - description: "AI-bot gespecialiseerd in het genereren van afbeeldingen met DALL-E 3" - discourse_helper: - name: "Discourse Helper" - description: "AI-bot gespecialiseerd in het helpen bij Discourse-gerelateerde taken" - web_artifact_creator: - name: "Webartefactmaker" - description: "AI-bot gespecialiseerd in het maken van interactieve webartefacten" - custom_prompt: - name: "Aangepaste prompt" - smart_dates: - name: "Slimme datums" - topic_not_found: "Samenvatting niet beschikbaar, topic niet gevonden!" - summarizing: "Topic samenvatten" - searching: "Zoeken naar: '%{query}'" - tool_options: - researcher: - max_results: - name: "Maximaal aantal resultaten" - google: - base_query: - name: "Basiszoekquery" - description: "Basisquery om te gebruiken bij het zoeken. Voorbeelden: 'site:example.com' levert alleen resultaten op van example.com, before:2022-01-01 levert alleen resultaten op van 2021 en eerder. Deze tekst wordt toegevoegd voorafgaand aan de zoekopdracht." - read: - read_private: - name: "Privé lezen" - description: "Sta toegang toe tot alle topics waartoe de gebruiker toegang heeft (standaard worden alleen openbare topics opgenomen)" - search: - search_private: - name: "Privé zoeken" - description: "Neem alle topics waartoe de gebruiker toegang heeft op in de zoekresultaten (standaard worden alleen openbare topics opgenomen)" - max_results: - name: "Maximaal aantal resultaten" - description: "Maximaal aantal resultaten dat in de zoekopdracht moet worden opgenomen. Indien leeg worden er lege standaardregels gebruikt en wordt het aantal geschaald, afhankelijk van het gebruikte model. De hoogste waarde is 100." - base_query: - name: "Basiszoekquery" - description: "Basisquery om te gebruiken bij het zoeken. Voorbeeld: '#urgent' voegt '#urgent' toe aan de zoekquery en neemt alleen topics mee met de categorie of tag 'urgent'." - tool_summary: - update_artifact: "Werk een webartefact bij" - create_artifact: "Webartefact maken" - web_browser: "Web surfen" - github_search_files: "GitHub-zoekbestanden" - github_search_code: "GitHub-code zoeken" - github_file_content: "GitHub-bestandsinhoud" - github_pull_request_diff: "GitHub pullverzoek-diff" - random_picker: "Willekeurige kiezer" - categories: "Categorieën weergeven" - search: "Zoeken" - tags: "Tags weergeven" - time: "Tijd" - summarize: "Samenvatten" - image: "Afbeelding genereren" - google: "Zoeken op Google" - read: "Topic lezen" - setting_context: "Site-instellingscontext opzoeken" - schema: "Databaseschema opzoeken" - search_settings: "Zoeken in site-instellingen" - dall_e: "Afbeelding genereren" - search_meta_discourse: "Zoeken in Meta Discourse" - javascript_evaluator: "JavaScript evalueren" - tool_help: - update_artifact: "Werk een webartefact bij met behulp van de AI-bot" - create_artifact: "Maak een webartefact met behulp van de AI-bot" - web_browser: "Webpagina bekijken met behulp van de AI-bot" - github_search_code: "Zoek code in een GitHub-repository" - github_search_files: "Zoek bestanden in een GitHub-repository" - github_file_content: "Haal de inhoud van bestanden op uit een GitHub-repository" - github_pull_request_diff: "Haal een GitHub pullverzoek-diff op" - random_picker: "Kies een willekeurig getal of een willekeurig element uit een lijst" - categories: "Geef een lijst weer van alle openbaar zichtbare categorieën op het forum" - search: "Doorzoek alle openbare topics op het forum" - tags: "Geef een lijst weer van alle tags op het forum" - time: "Zoek de tijd in verschillende tijdzones" - summary: "Vat een topic samen" - image: "Afbeelding genereren met Stable Diffusion" - google: "Query zoeken op Google" - read: "Openbaar topic op forum lezen" - setting_context: "Site-instellingscontext opzoeken" - schema: "Databaseschema opzoeken" - search_settings: "Site-instellingen zoeken" - dall_e: "Afbeelding genereren met DALL-E 3" - search_meta_discourse: "Zoeken in Meta Discourse" - javascript_evaluator: "JavaScript evalueren" - tool_description: - update_artifact: "Webartefact bijgewerkt met behulp van de AI-bot" - web_browser: "Lezen: %{url}" - github_search_files: "Gezocht naar '%{keywords}' in %{repo}/%{branch}" - github_search_code: "Gezocht naar '%{query}' in %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Inhoud van %{file_paths} opgehaald uit %{repo_name}@%{branch}" - random_picker: "Kiezen uit %{options}, gekozen: %{result}" - read: "Lezen: %{title}" - time: "De tijd in %{timezone} is %{time}" - summarize: "Samengevat: %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "%{count} categorie gevonden" - other: "%{count} categorieën gevonden" - tags: - one: "%{count} tag gevonden" - other: "%{count} tags gevonden" - search: - one: "%{count} resultaat gevonden voor '%{query}'" - other: "%{count} resultaten gevonden voor '%{query}'" - search_meta_discourse: - one: "%{count} resultaat gevonden voor '%{query}'" - other: "%{count} resultaten gevonden voor '%{query}'" - google: - one: "%{count} resultaat gevonden voor '%{query}'" - other: "%{count} resultaten gevonden voor '%{query}'" - setting_context: "Context lezen voor: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "%{count} resultaat gevonden voor '%{query}'" - other: "%{count} resultaten gevonden voor '%{query}'" - summarization: - configuration_hint: - one: "Configureer eerst de instelling '%{setting}'." - other: "Configureer eerst deze instellingen: '%{settings}'." - chat: - no_targets: "Er waren geen berichten tijdens de geselecteerde periode." - sentiment: - reports: - overall_sentiment: "Algeheel sentiment (positief - negatief)" - post_emotion: - sadness: "Droefheid \U0001F622" - surprise: "Verrassing \U0001F631" - neutral: "Neutraal \U0001F610" - fear: "Angst \U0001F628" - anger: "Boosheid \U0001F621" - joy: "Vreugde \U0001F600" - disgust: "Afschuw \U0001F922" - sentiment_analysis: - positive: "Positief" - negative: "Negatief" - neutral: "Neutraal" - llm: - configuration: - disable_module_first: "Je moet eerst %{setting} uitschakelen." - set_llm_first: "Stel eerst %{setting} in" - model_unreachable: "We konden geen antwoord krijgen van dit model. Controleer eerst je instellingen." - invalid_seeded_model: "Je kunt dit model niet gebruiken met deze functie" - must_select_model: "Je moet eerst een LLM selecteren" - endpoints: - not_configured: "%{display_name} (niet geconfigureerd)" - configuration_hint: - one: "Zorg dat de instelling `%{settings}` is geconfigureerd." - other: "Zorg dat deze instellingen zijn geconfigureerd: %{settings}" - delete_failed: - one: "We kunnen dit model niet verwijderen omdat %{settings} het gebruikt. Werk de instelling bij en probeer het opnieuw." - other: "We kunnen dit model niet verwijderen omdat %{settings} het gebruiken. Werk de instellingen bij en probeer het opnieuw." - cannot_edit_builtin: "Je kunt een ingebouwd model niet bewerken." - embeddings: - delete_failed: "Dit model is momenteel in gebruik. Werk eerst 'ai embeddings selected model' bij." - cannot_edit_builtin: "Je kunt een ingebouwd model niet bewerken." - configuration: - disable_embeddings: "Je moet 'ai embeddings enabled' eerst uitschakelen." - choose_model: "Stel eerst 'ai embeddings selected model' in." - llm_models: - missing_provider_param: "%{param} mag niet leeg zijn" - bedrock_invalid_url: "Vul alle velden in om dit model te gebruiken." - ai_staff_action_logger: - updated: "bijgewerkt" - removed: "verwijderd" - errors: - quota_exceeded: "Je hebt het quotum voor dit model overschreden. Probeer het opnieuw over %{relative_time}." - quota_required: "Je moet het maximale aantal tokens of gebruiken opgeven voor dit model" - no_query_specified: De queryparameter is verplicht. Geef deze op. - no_user_for_persona: Er is geen gebruiker gekoppeld aan de opgegeven persona. - persona_not_found: De opgegeven persona bestaat niet. Controleer de parameters persona_name en persona_id. - no_user_specified: De gebruikersnaam of de parameter user_unique_id is vereist. Geef deze op. - user_not_found: De opgegeven gebruiker bestaat niet. Controleer de gebruikersnaamparameter. - persona_disabled: De opgegeven persona is uitgeschakeld. Controleer de parameters persona_name en persona_id. - no_default_llm: Er moet een default_llm zijn gedefinieerd voor de persona. - user_not_allowed: De gebruiker mag niet deelnemen aan het topic. - prompt_message_length: Het bericht %{idx} is langer dan de limiet van 1000 tekens. diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml deleted file mode 100644 index a18b339a..00000000 --- a/config/locales/server.pl_PL.yml +++ /dev/null @@ -1,286 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -pl_PL: - discourse_automation: - ai: - flag_types: - spam: "Oznacz jako spam i ukryj post" - spam_silence: "Oznacz jako spam, ukryj post i wycisz użytkownika" - scriptables: - llm_triage: - title: Oceniaj posty za pomocą sztucznej inteligencji - description: "Oceniaj posty przy użyciu dużego modelu językowego" - llm_report: - title: Raport okresowy z wykorzystaniem AI - description: "Okresowy raport oparty na dużym modelu językowym" - site_settings: - discourse_ai_enabled: "Włącz wtyczkę Discourse AI." - ai_toxicity_enabled: "Włącz moduł toksyczności." - ai_toxicity_inference_service_api_endpoint: "Adres URL, pod którym działa interfejs API modułu toksyczności" - ai_toxicity_inference_service_api_key: "Klucz dla API toksyczności" - ai_toxicity_inference_service_api_model: "Model używany do wnioskowania. Model wielojęzyczny współpracuje z włoskim, francuskim, rosyjskim, portugalskim, hiszpańskim i tureckim." - ai_toxicity_flag_automatically: "Automatycznie oflaguj posty / wiadomości na czacie, które przekraczają skonfigurowane progi." - ai_toxicity_flag_threshold_toxicity: "Toksyczność: niegrzeczny, lekceważący lub nierozsądny komentarz, który może sprawić, że opuścisz dyskusję lub zrezygnujesz z dzielenia się swoją perspektywą" - ai_toxicity_flag_threshold_severe_toxicity: "Poważna toksyczność: bardzo nienawistny, agresywny lub lekceważący komentarz, który najprawdopodobniej sprawi, że opuścisz dyskusję lub zrezygnujesz z dzielenia się swoją perspektywą" - ai_toxicity_flag_threshold_obscene: "Nieprzyzwoity" - ai_toxicity_flag_threshold_identity_attack: "Atak na tożsamość" - ai_toxicity_flag_threshold_insult: "Znieważenie" - ai_toxicity_flag_threshold_threat: "Zagrożenie" - ai_toxicity_flag_threshold_sexual_explicit: "Treści erotyczne" - ai_toxicity_groups_bypass: "Posty użytkowników w tych grupach nie będą klasyfikowane według modułu toksyczności." - ai_sentiment_enabled: "Włącz moduł sentymentu." - ai_sentiment_inference_service_api_endpoint: "Adres URL, pod którym działa interfejs API dla modułu sentymentu" - ai_sentiment_inference_service_api_key: "Klucz dla API sentymentu" - ai_sentiment_models: "Modele używane do wnioskowania. Sentyment klasyfikuje post w przestrzeni pozytywnej/neutralnej/negatywnej. Emocje klasyfikuje się w przestrzeni gniewu/wstrętu/strachu/radości/neutralności/smutku/zaskoczenia." - ai_nsfw_detection_enabled: "Włącz moduł NSFW." - ai_nsfw_inference_service_api_endpoint: "Adres URL, pod którym działa interfejs API dla modułu NSFW" - ai_nsfw_inference_service_api_key: "Klucz dla NSFW API" - ai_nsfw_flag_automatically: "Automatycznie oflaguj posty NSFW, które przekraczają skonfigurowane progi." - ai_nsfw_flag_threshold_general: "Ogólny próg dla obrazu, który ma zostać uznany za NSFW." - ai_nsfw_flag_threshold_drawings: "Próg uznania rysunku za NSFW." - ai_nsfw_flag_threshold_hentai: "Próg uznania obrazu zaklasyfikowanego jako hentai, aby został uznany za NSFW." - ai_nsfw_flag_threshold_porn: "Próg uznania obrazu zaklasyfikowanego jako pornograficzny, aby został uznany za NSFW." - ai_nsfw_flag_threshold_sexy: "Próg uznania obrazu zaklasyfikowanego jako seksowny, aby został uznany za NSFW." - ai_nsfw_models: "Modele używane do wnioskowania NSFW." - composer_ai_helper_allowed_groups: "Użytkownicy w tych grupach zobaczą przycisk pomocy AI w kompozytorze." - ai_helper_allowed_in_pm: "Włącz asystenta AI kompozytora w PW." - ai_helper_model: "Model do użycia dla pomocnika AI." - ai_helper_custom_prompts_allowed_groups: "Użytkownicy w tych grupach zobaczą opcję niestandardowego monitu w pomocniku AI." - ai_helper_automatic_chat_thread_title_delay: "Opóźnienie w minutach, zanim pomocnik AI automatycznie ustawi tytuł wątku czatu." - ai_helper_automatic_chat_thread_title: "Automatycznie ustawiaj tytuły wątków czatu na podstawie zawartości wątku." - ai_embeddings_generate_for_pms: "Generuj osadzenia dla wiadomości osobistych." - ai_embeddings_semantic_related_topics_enabled: "Użyj wyszukiwania semantycznego dla powiązanych tematów." - ai_embeddings_semantic_related_topics: "Maksymalna liczba tematów do wyświetlenia w sekcji powiązanych tematów." - ai_embeddings_backfill_batch_size: "Liczba osadzań do uzupełnienia co 15 minut." - ai_embeddings_semantic_search_enabled: "Włącz wyszukiwanie semantyczne na całej stronie." - ai_embeddings_semantic_related_include_closed_topics: "Uwzględnij zamknięte tematy w semantycznych wynikach wyszukiwania" - ai_embeddings_semantic_search_hyde_model: "Model używany do rozwijania słów kluczowych w celu uzyskania lepszych wyników podczas wyszukiwania semantycznego" - ai_summarization_model: "Model używany do podsumowania" - ai_custom_summarization_allowed_groups: "Grupy, które mogą tworzyć nowe podsumowania." - ai_pm_summarization_allowed_groups: "Grupy mogą tworzyć i przeglądać podsumowania w wiadomościach prywatnych." - ai_summary_gists_enabled: "Automatycznie generuj krótkie podsumowania najnowszych odpowiedzi w tematach" - ai_summary_gists_allowed_groups: "Grupy mogą przeglądać sedno dyskusji na liście gorących tematów." - ai_summary_backfill_maximum_topics_per_hour: "Liczba podsumowań tematów do uzupełnienia na godzinę." - ai_bot_enabled: "Włącz moduł bota AI." - ai_bot_enable_chat_warning: "Wyświetl ostrzeżenie po zainicjowaniu czatu PW. Można je zastąpić, edytując ciąg tłumaczenia: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Gdy bot GPT uzyska dostęp do PW, odpowie członkom tych grup." - ai_bot_add_to_header: "Wyświetl przycisk w nagłówku, aby rozpocząć PW z botem AI" - ai_stability_api_key: "Klucz dla API stability.ai" - ai_stability_engine: "Silnik generowania obrazów do wykorzystania w stability.ai API" - ai_stability_api_url: "Adres URL interfejsu API stability.ai" - ai_google_custom_search_api_key: "Klucz dla Google Custom Search API, zobacz: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX dla Google Custom Search API" - reviewables: - reasons: - flagged_by_toxicity: Wtyczka AI oznaczyła to po sklasyfikowaniu jako toksyczne. - flagged_by_nsfw: Wtyczka AI oznaczyła to po sklasyfikowaniu co najmniej jednego z załączonych obrazów jako NSFW. - reports: - overall_sentiment: - title: "Ogólny sentyment" - xaxis: "Pozytywny(%)" - yaxis: "Data" - emotion_anger: - title: "\U0001F620 Gniew" - emotion_disgust: - title: "\U0001F922 Niesmak" - emotion_fear: - title: "\U0001F628 Strach" - emotion_neutral: - title: "\U0001F610 Neutralne" - emotion_sadness: - title: "\U0001F62D Smutek" - emotion_surprise: - title: "\U0001F632 Niespodzianka" - discourse_ai: - ai_artifact: - view_source: "Zobacz źródło" - view_changes: "Zobacz zmiany" - change_description: "Zmień opis" - unknown_model: "Nieznany model AI" - ai_helper: - errors: - completion_request_failed: "Coś poszło nie tak podczas próby przedstawienia sugestii. Spróbuj ponownie." - prompts: - translate: Przetłumacz na %{language} - generate_titles: Zaproponuj tytuły tematu - proofread: Popraw tekst - markdown_table: Wygeneruj tabelę Markdown - custom_prompt: "Niestandardowy monit" - explain: "Wyjaśnij" - illustrate_post: "Zilustruj post" - share_ai: - read_more: "Przeczytaj pełną transkrypcję" - errors: - not_allowed: "Nie masz uprawnień do udostępniania tego tematu" - failed_to_share: "Nie udało się udostępnić konwersacji" - conversation_deleted: "Udostępniona konwersacja została pomyślnie usunięta" - spam_detection: - flag_reason: "Oznaczono jako spam przez Discourse AI" - silence_reason: "Użytkownik wyciszony automatycznie przez Discourse AI" - invalid_error_type: "Podano nieprawidłowy typ błędu" - unexpected: "Wystąpił nieoczekiwany błąd" - bot_user_update_failed: "Nie udało się zaktualizować użytkownika bota skanującego spam" - ai_bot: - default_pm_prefix: "[PW bota AI bez tytułu]" - thinking: "Myślenie..." - personas: - cannot_delete_system_persona: "Person systemowych nie można usunąć, zamiast tego wyłącz je." - general: - name: Pomocnik forumowy - description: "Bot AI ogólnego przeznaczenia zdolny do wykonywania różnych zadań" - artist: - name: Artysta - description: "Bot AI specjalizujący się w generowaniu obrazów" - sql_helper: - name: Pomocnik SQL - description: "Bot AI specjalizujący się w pomaganiu w tworzeniu zapytań SQL na tej instancji Discourse." - settings_explorer: - name: Eksplorator ustawień - description: "Bot AI specjalizujący się w eksploracji ustawień strony Discourse" - creative: - name: Kreatywny - description: "Bot AI bez zewnętrznych integracji specjalizujący się w zadaniach kreatywnych" - dall_e3: - name: "DALL-E 3" - description: "Bot AI specjalizujący się w generowaniu obrazów przy użyciu DALL-E 3" - short_summarizer: - name: "Podsumowanie (forma krótka)" - custom_prompt: - name: "Niestandardowy prompt" - topic_not_found: "Podsumowanie niedostępne, nie znaleziono tematu!" - summarizing: "Podsumowanie tematu" - searching: "Wyszukiwanie: '%{query}'" - tool_options: - researcher: - max_results: - name: "Maksymalna liczba wyników" - update_artifact: - update_algorithm: - description: "Poproś LLM o pełne zastąpienie lub użyj diff do aktualizacji" - google: - base_query: - name: "Podstawowe zapytanie wyszukiwania" - search: - max_results: - name: "Maksymalna liczba wyników" - description: "Maksymalna liczba wyników do uwzględnienia w wyszukiwaniu - jeśli zostaną użyte puste reguły domyślne, liczba zostanie przeskalowana w zależności od użytego modelu. Najwyższa wartość to 100." - base_query: - name: "Podstawowe zapytanie wyszukiwania" - description: "Podstawowe zapytanie używane podczas wyszukiwania. Przykład: '#pilne' spowoduje dodanie '#pilne' do zapytania wyszukiwania i uwzględnienie tylko tematów z kategorią lub tagiem pilne." - tool_summary: - web_browser: "Przeglądaj sieć" - random_picker: "Losowy selektor" - categories: "Wymień kategorie" - search: "Szukaj" - tags: "Wymień tagi" - time: "Czas" - summarize: "Podsumuj" - image: "Wygeneruj obraz" - google: "Szukaj w Google" - read: "Przeczytaj temat" - setting_context: "Wyszukaj kontekst ustawienia witryny" - schema: "Wyszukaj schemat bazy danych" - search_settings: "Wyszukiwanie ustawień witryny" - dall_e: "Wygeneruj obraz" - tool_help: - random_picker: "Wybierz losową liczbę lub losowy element listy" - categories: "Wyświetl wszystkie publicznie widoczne kategorie na forum" - search: "Przeszukaj wszystkie publiczne tematy na forum" - tags: "Wymień wszystkie tagi na forum" - time: "Znajdź czas w różnych strefach czasowych" - summary: "Podsumuj temat" - image: "Wygeneruj obraz przy użyciu Stable Diffusion" - google: "Wyszukaj zapytanie w Google" - read: "Przeczytaj temat publiczny na forum" - setting_context: "Wyszukaj kontekst ustawienia witryny" - schema: "Wyszukaj schemat bazy danych" - search_settings: "Wyszukaj ustawienia witryny" - dall_e: "Wygeneruj obraz za pomocą DALL-E 3" - tool_description: - random_picker: "Wybieranie z %{options}, wybrane: %{result}" - read: "Czytanie: %{title}" - time: "Czas w %{timezone} wynosi %{time}" - summarize: "Podsumowano %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "Znaleziono %{count} kategorię" - few: "Znaleziono %{count} kategorie" - many: "Znaleziono %{count} kategorii" - other: "Znaleziono %{count} kategorii" - tags: - one: "Znaleziono %{count} tag" - few: "Znaleziono %{count} tagi" - many: "Znaleziono %{count} tagów" - other: "Znaleziono %{count} tagów" - search: - one: "Znaleziono %{count} wyników dla '%{query}'" - few: "Znaleziono %{count} wyniki dla '%{query}'" - many: "Znaleziono %{count} wyników dla '%{query}'" - other: "Znaleziono %{count} wyników dla '%{query}'" - search_meta_discourse: - one: "Znaleziono %{count} wyników dla '%{query}'" - few: "Znaleziono %{count} wyniki dla '%{query}'" - many: "Znaleziono %{count} wyników dla '%{query}'" - other: "Znaleziono %{count} wyników dla '%{query}'" - google: - one: "Znaleziono %{count} wyników dla '%{query}'" - few: "Znaleziono %{count} wyniki dla '%{query}'" - many: "Znaleziono %{count} wyników dla '%{query}'" - other: "Znaleziono %{count} wyników dla '%{query}'" - setting_context: "Kontekst czytania dla: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "Znaleziono %{count} wynik dla '%{query}'" - few: "Znaleziono %{count} wyniki dla '%{query}'" - many: "Znaleziono %{count} wyników dla '%{query}'" - other: "Znaleziono %{count} wyników dla '%{query}'" - summarization: - configuration_hint: - one: "Najpierw skonfiguruj ustawienie `%{setting}`." - few: "Najpierw skonfiguruj ustawienia: %{settings}" - many: "Najpierw skonfiguruj ustawienia: %{settings}" - other: "Najpierw skonfiguruj ustawienia: %{settings}" - chat: - no_targets: "W wybranym okresie nie było żadnych wiadomości." - sentiment: - reports: - post_emotion: - sadness: "Smutek \U0001F622" - surprise: "Niespodzianka \U0001F631" - neutral: "Neutralne \U0001F610" - fear: "Strach \U0001F628" - anger: "Gniew \U0001F621" - disgust: "Niesmak \U0001F922" - sentiment_analysis: - positive: "Pozytywne" - negative: "Negatywne" - neutral: "Neutralne" - llm: - configuration: - disable_module_first: "Najpierw musisz wyłączyć %{setting}." - set_llm_first: "Najpierw ustaw %{setting}" - model_unreachable: "Nie mogliśmy uzyskać odpowiedzi od tego modelu. Sprawdź najpierw swoje ustawienia." - embeddings: - cannot_edit_builtin: "Nie możesz edytować wbudowanego modelu." - configuration: - invalid_config: "Wybrałeś nieprawidłową opcję." - llm_models: - missing_provider_param: "%{param} nie może być pusty" - bedrock_invalid_url: "Wypełnij wszystkie pola, aby korzystać z tego modelu." - ai_staff_action_logger: - updated: "zaktualizowane" - removed: "usunięto" - errors: - quota_exceeded: "Przekroczyłeś limit dla tego modelu. Spróbuj ponownie za %{relative_time}." - quota_required: "Musisz określić maksymalną liczbę tokenów lub użyć dla tego modelu" - no_query_specified: Parametr zapytania jest wymagany, podaj go. - no_user_for_persona: Określona persona nie ma powiązanego użytkownika. - persona_not_found: Określona persona nie istnieje. Sprawdź parametry persona_name lub persona_id. - prompt_message_length: Wiadomość %{idx} przekracza limit 1000 znaków. diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml deleted file mode 100644 index be9bd2b7..00000000 --- a/config/locales/server.pt.yml +++ /dev/null @@ -1,19 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -pt: - reports: - overall_sentiment: - yaxis: "Data" - discourse_ai: - ai_bot: - tool_summary: - search: "Pesquisar" - time: "Hora" - summarize: "Resumir" - ai_staff_action_logger: - updated: "atualizado" - removed: "removido" diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml deleted file mode 100644 index 75bd3c46..00000000 --- a/config/locales/server.pt_BR.yml +++ /dev/null @@ -1,444 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -pt_BR: - discourse_automation: - ai: - flag_types: - review: "Adicionar postagem à fila de revisão" - spam: "Sinalizar como spam e ocultar postagem" - spam_silence: "Sinalizar como spam, ocultar postagem e silenciar usuário(a)" - scriptables: - llm_triage: - title: Postagens de triagem usando IA - description: "Postagens de triagem usando um modelo de linguagem grande" - flagged_post: | -
Resposta do modelo:
-

%%LLM_RESPONSE%%

- Acionada pela regra %%AUTOMATION_NAME%%. - llm_report: - title: Relatório periódico usando IA - description: "Relatório periódico baseado em um modelo de linguagem grande" - site_settings: - discourse_ai_enabled: "Ative o plugin de IA do Discourse." - ai_toxicity_enabled: "Ative o módulo de toxicidade." - ai_toxicity_inference_service_api_endpoint: "URL em que a API está em execução para o módulo de toxicidade" - ai_toxicity_inference_service_api_key: "Chave de API para a API de toxicidade" - ai_toxicity_inference_service_api_model: "Modelo usado para inferência. O modelo multilinguístico funciona com italiano, francês, russo, português, espanhol e turco." - ai_toxicity_flag_automatically: "Sinalize automaticamente postagens/mensagens de chat que excedem os limites configurados." - ai_toxicity_flag_threshold_toxicity: "Toxicidade: comentário grosseiro, desrespeitoso ou insensato com alguma probabilidade de causar a saída da discussão ou desistência de compartilhar opinião." - ai_toxicity_flag_threshold_severe_toxicity: "Toxicidade grave: comentário muito desrespeitoso, agressivo ou odioso com alta probabilidade de causar a saída da discussão ou desistência de compartilhar opinião." - ai_toxicity_flag_threshold_obscene: "Obsceno" - ai_toxicity_flag_threshold_identity_attack: "Ataque à identidade" - ai_toxicity_flag_threshold_insult: "Insulto" - ai_toxicity_flag_threshold_threat: "Ameaça" - ai_toxicity_flag_threshold_sexual_explicit: "Conteúdo sexual explícito" - ai_toxicity_groups_bypass: "Os(as) usuários(as) não terão suas postagens classificadas pelo módulo de toxicidade." - ai_sentiment_enabled: "Ative o módulo de sentimento." - ai_sentiment_inference_service_api_endpoint: "URL em que a API está em execução para o módulo de sentimento" - ai_sentiment_inference_service_api_key: "Chave de API para a API de sentimento" - ai_sentiment_models: "Modelos usado para inferência. O sentimento classifica a postagem em positiva/neutra/negativa. A emoção classifica em ódio/repúdio/medo/alegria/neutro/tristeza/surpresa." - ai_nsfw_detection_enabled: "Ative o módulo NSFW." - ai_nsfw_inference_service_api_endpoint: "URL em que a API está em execução para o módulo NSFW" - ai_nsfw_inference_service_api_key: "Chave de API para a API de NSFW" - ai_nsfw_flag_automatically: "Sinalize automaticamente postagens de NSFW que excedem os limites configurados." - ai_nsfw_flag_threshold_general: "Limite geral para uma imagem ser considerada NSFW." - ai_nsfw_flag_threshold_drawings: "Limite para um desenho ser considerado NSFW." - ai_nsfw_flag_threshold_hentai: "Limite para uma imagem classificada como hentai ser considerada NSFW." - ai_nsfw_flag_threshold_porn: "Limite para uma imagem classificada como pornográfica ser considerada NSFW." - ai_nsfw_flag_threshold_sexy: "Limite para uma imagem classificada como sensual ser considerada NSFW." - ai_nsfw_models: "Modelos usados para inferência de NSFW." - ai_helper_enabled: "Habilite o assistente de IA." - composer_ai_helper_allowed_groups: "Os usuários desses grupos visualizarão o botão de assistência por IA no compositor." - ai_helper_allowed_in_pm: "Ative o ajudante de IA do compositor em MPs." - ai_helper_model: "Modelo usado para o ajudante de IA." - ai_helper_custom_prompts_allowed_groups: "Os(as) usuários(as) que estiverem nestes grupos verão um prompt personalizado no ajudante da IA." - ai_helper_automatic_chat_thread_title_delay: "O atraso, em minutos, até que o ajudante de IA defina automaticamente o título da thread do chat." - ai_helper_automatic_chat_thread_title: "Defina automaticamente os títulos das linhas de discussão do chat com base no conteúdo delas." - ai_helper_illustrate_post_model: "O modelo a ser usado para o ajudante de IA ilustrar o recurso da postagem" - ai_helper_enabled_features: "Selecione os recursos para ativar no ajudante de IA." - post_ai_helper_allowed_groups: "Grupos de usuários(as) com permissão para acessar recursos do ajudante de IA nas postagens" - ai_helper_image_caption_model: "Selecione o modelo a ser usado para gerar legendas em imagens" - ai_auto_image_caption_allowed_groups: "Usuários(as) nestes grupos podem alternar legendas automáticas em imagens." - ai_embeddings_selected_model: "Use o modelo selecionado para gerar incorporações" - ai_embeddings_generate_for_pms: "Gere incorporações para mensagens pessoais." - ai_embeddings_semantic_related_topics_enabled: "Use Pesquisa semântica para tópicos relacionados." - ai_embeddings_semantic_related_topics: "Número máximo de tópicos na seção do tópico relacionado." - ai_embeddings_backfill_batch_size: "O número de incorporações para provisionamento a cada 15 minutos." - ai_embeddings_semantic_search_enabled: "Ative pesquisa semântica em toda a página." - ai_embeddings_semantic_quick_search_enabled: "Ative opção de busca semântica no pop-up no menu de busca." - ai_embeddings_semantic_related_include_closed_topics: "Incluir tópicos fechados em resultados de pesquisa semântica" - ai_embeddings_semantic_search_hyde_model: "O modelo usado para expandir palavras-chave e obter resultados melhores durante uma pesquisa semântica" - ai_embeddings_per_post_enabled: Gerar incorporações para cada postagem - ai_summarization_model: "Modelo a ser usado para geração de resumos" - ai_custom_summarization_allowed_groups: "Grupos com permissão para a criação de resumos novos." - ai_pm_summarization_allowed_groups: "Grupos autorizados a criar e visualizar resumos em PMs." - ai_summary_gists_allowed_groups: "Grupos com permissão para ver gists na lista de tópicos mais acessados." - ai_summary_backfill_maximum_topics_per_hour: "Quantidade de tópicos para preencher a cada hora." - ai_bot_enabled: "Ative o módulo de Bot com IA." - ai_bot_enable_chat_warning: "Exiba um aviso quando o chat por MP for iniciado. Pode ser substituído ao editar a string de tradução: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Quando o Bot com GPT tiver acesso às MPs, ele responderá aos membros destes grupos." - ai_bot_debugging_allowed_groups: "Permitir a exibição de um botão de depuração nestes grupos, em postagens em que são exibidas respostas e pedidos de IA não processados" - ai_bot_public_sharing_allowed_groups: "Permita que estes grupos compartilhem mensagens pessoais de IA com o público através de um link exclusivo disponível publicamente. Observação: se for preciso entrar com sua conta no site, também será preciso para o compartilhamento." - ai_bot_add_to_header: "Exibir botão no cabeçalho para começar uma MP com um Bot de IA" - ai_bot_github_access_token: "Token de acesso do GitHub a ser usado com ferramentas de IA do GitHub (necessário para ter compatibilidade com pesquisa)" - ai_stability_api_key: "Chave de API para a API stability.ai" - ai_stability_engine: "Mecanismo de geração de imagem usado para a API stability.ai" - ai_stability_api_url: "URL para a API stability.ai" - ai_google_custom_search_api_key: "Chave de API para a API de pesquisa personalizada do Google. Veja: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX para API de pesquisa personalizada do Google" - reviewables: - reasons: - flagged_by_toxicity: O plugin de IA sinalizou isto após classificar como tóxico. - flagged_by_nsfw: O plugin de IA sinalizou isto após classificar pelo menos uma das imagens anexas como NSFW. - reports: - overall_sentiment: - title: "Sentimento geral" - description: 'Este gráfico compara a quantidade de postagens classificadas como positiva ou negativa. São calculadas quando as pontuações negativas e positivas forem maiores que a pontuação de limite. Ou seja, postagens neutras não são exibidas. Mensagens pessoais (PM) também não. Classificado com "cardiffnlp/twitter-roberta-base-sentiment-latest"' - xaxis: "Positivo (%)" - yaxis: "Data" - emotion_admiration: - title: "\U0001F929 Admiração" - description: "Postagens classificadas com o gesto de admiração pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_amusement: - title: "\U0001F604 Diversão" - description: "Postagens classificadas com o gesto de diversão pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_anger: - title: "\U0001F620 Raiva" - description: "Postagens classificadas com o gesto de raiva pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_annoyance: - title: "\U0001F612 Aborrecimento" - description: "Postagens classificadas com o gesto de aborrecimento pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_approval: - title: "\U0001F44D Aprovação" - description: "Postagens classificadas com o gesto de aprovação pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_caring: - title: "\U0001F917 Carinho" - description: "Postagens classificadas com o gesto de carinho pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_confusion: - title: "\U0001F615 Confusão" - description: "Postagens classificadas com o gesto de confusão pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_curiosity: - title: "\U0001F914 Curiosidade" - description: "Postagens classificadas com o gesto de curiosidade pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_desire: - title: "\U0001F60D Desejo" - description: "Postagens classificadas com o gesto de desejo pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_disappointment: - title: "\U0001F61E Decepção" - description: "Postagens classificadas com o gesto de decepção pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_disapproval: - title: "\U0001F44E Desaprovação" - description: "Postagens classificadas com o gesto de desaprovação pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_disgust: - title: "\U0001F922 Repulsa" - description: "Postagens classificadas com o gesto de desgosto pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_embarrassment: - title: "\U0001F633 Vergonha" - description: "Postagens classificadas com o gesto de vergonha pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_excitement: - title: "\U0001F92A Empolgação" - description: "Postagens classificadas com o gesto de empolgação pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_fear: - title: "\U0001F628 Medo" - description: "Postagens classificadas com o gesto de medo pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_gratitude: - title: "\U0001F64F Gratidão" - description: "Postagens classificadas com o gesto de gratidão pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_grief: - title: "\U0001F622 Pesar" - description: "Postagens classificadas com o gesto de pesar pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_joy: - title: "\U0001F60A Alegria" - description: "Postagens classificadas com o gesto de alegria pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_love: - title: '❤️ Amor' - description: "Postagens classificadas com o gesto de amor pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_nervousness: - title: "\U0001F630 Nervosismo" - description: "Postagens classificadas com o gesto de nervosismo pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_neutral: - title: "\U0001F610 Neutro" - description: "Postagens classificadas com o gesto de neutralidade pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_optimism: - title: "\U0001F31F Otimismo" - description: "Postagens classificadas com o gesto de otimismo pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_pride: - title: "\U0001F981 Orgulho" - description: "Postagens classificadas com o gesto de orgulho pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_realization: - title: "\U0001F4A1 Percepção" - description: "Postagens classificadas com o gesto de percepção pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_relief: - title: "\U0001F60C Alívio" - description: "Postagens classificadas com o gesto de alívio pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_remorse: - title: "\U0001F614 Remorso" - description: "Postagens classificadas com o gesto de remorso pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_sadness: - title: "\U0001F62D Tristeza" - description: "Postagens classificadas com o gesto de tristeza pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - emotion_surprise: - title: "\U0001F632 Surpresa" - description: "Postagens classificadas com o gesto de surpresa pela IA, usando o modelo \"SamLowe/roberta-base-go_emotions\"." - discourse_ai: - ai_artifact: - view_source: "Ver origem" - view_changes: "Visualizar alterações" - unknown_model: "Modelo de IA desconhecido" - tools: - custom_name: "%{name} (personalizado)" - presets: - browse_web_jina: - name: "Navegar na Web com (jina.ai)" - exchange_rate: - name: "Taxa de câmbio" - stock_quote: - name: "Cotação de ação (AlphaVantage)" - image_generation: - name: "Gerador de imagem de fluxo (Together.ai)" - empty_tool: - name: "Começar do zero..." - ai_helper: - errors: - completion_request_failed: "Algo deu errado ao tentar dar sugestões. Tente novamente." - prompts: - translate: Traduzir para %{language} - generate_titles: Sugerir títulos de tópicos - proofread: Revisar texto - markdown_table: Gerar tabela de Markdown - custom_prompt: "Prompt personalizado" - explain: "Explicar" - illustrate_post: "Ilustrar postagem" - replace_dates: "Datas inteligentes" - painter: - attribution: - stable_diffusion_xl: "Imagem criada por Stable Diffusion XL" - dall_e_3: "Imagem criada por DALL-E 3" - image_caption: - attribution: "Legendado por IA" - share_ai: - read_more: "Ler a transcrição completa" - onebox_title: "Convesa de IA com %{llm_name}" - formatted_excerpt: "Convesa de IA com %{llm_name}:\n %{excerpt}" - title: "%{title} - Conversa de AI - %{site_name}" - errors: - not_allowed: "Você não tem permissão para compartilhar este tópico" - other_people_in_pm: "Não é possível compartilhar publicamente mensagens pessoais com outros humanos" - other_content_in_pm: "Não é possível compartilhar publicamente mensagens pessoais que contêm postagens de outras pessoas" - failed_to_share: "Falha ao compartilhar conversa" - conversation_deleted: "Compartilhamento de conversa excluído" - spam_detection: - flag_reason: "Sinalizada como spam pelo Discourse AI" - silence_reason: "Usuário(a) silenciado(a) automaticamente pelo Discourse AI" - invalid_error_type: "Tipo inválido de erro fornecido" - unexpected: "Ocorreu um erro inesperado" - bot_user_update_failed: "Falha ao atualizar usuário(a) do bot de verificação de spam" - ai_bot: - reply_error: "Desculpe, parece que nosso sistema encontrou um problema inesperado ao tentar responder.\n\n[details='Error details']\n%{details}\n[/details]" - default_pm_prefix: "[MP de bot de IA não identificado]" - personas: - default_llm_required: "É preciso de um modelo de LLM padrão para ativar o chat" - cannot_delete_system_persona: "Personas de sistema não podem ser excluídas, desative" - cannot_edit_system_persona: "Personas de sistema só podem ser renomeadas, você não pode editar ferramentas ou prompt de sistema. Desative e faça uma cópia" - github_helper: - name: "Ajudante do GitHub" - description: "Bot de IA especializado em auxiliar em tarefas e perguntas relacionadas ao GitHub" - general: - name: Ajudante do fórum - description: "Bot de IA de propósito geral capaz de realizar várias tarefas" - artist: - name: Artista - description: "Bot de IA especializado em gerar imagens" - sql_helper: - name: Ajudante SQL - description: "Bot de IA especializado em ajudar a criar consultas SQL nesta instância do Discourse" - settings_explorer: - name: Explorador de configurações - description: "Bot de IA especializado em ajudar a explorar as configurações do site do Discourse" - creative: - name: Criativa - description: "Bot de IA sem integrações externas especializado em tarefas criativas" - dall_e3: - name: "DALL-E 3" - description: "Bot de IA especializado em gerar imagens usando o DALL-E 3" - discourse_helper: - name: "Ajudante do Discourse" - description: "Bot de IA especializado em ajudar em tarefas relacionadas ao Discourse" - web_artifact_creator: - name: "Criador de artefato da web" - description: "Bot especializado na criação de artefatos interativos da web" - custom_prompt: - name: "Prompt personalizado" - smart_dates: - name: "Datas inteligentes" - topic_not_found: "Resumo indisponível, tópico não encontrado!" - summarizing: "Resumindo tópico" - searching: "Pesquisando: \"%{query}\"" - tool_options: - researcher: - max_results: - name: "O número máximo de resultados" - google: - base_query: - name: "Consulta de pesquisa básica" - description: "Consulta de base para usar em pesquisas. Por exemplo: \"site:example.com\" incluirá apenas resultados de example.com, before:2022-01-01 apenas resultados de 2021 em diante. Presume-se que este texto é a consulta de pesquisa." - read: - read_private: - name: "Ler conteúdo privado" - description: "Permitir acesso a todos os tópicos que o(a) usuário(a) pode acessar (por padrão, apenas tópicos públicos estão inclusos)" - search: - search_private: - name: "Pesquisa de conteúdo privado" - description: "Incluir todos os tópicos que o(a) usuário(a) pode acessar nos resultados de pesquisa (por padrão, apenas tópicos públicos estão inclusos)" - max_results: - name: "O número máximo de resultados" - description: "A quantidade máxima de resultados para incluir na pesquisa. Se for vazio, serão usadas as regras padrão e a contagem será dimensionada conforme o modelo utilizado. O valor máximo é 100." - base_query: - name: "Consulta de pesquisa básica" - description: "A consulta de base para usar na pesquisa. Exemplo: \"#urgent\" precederá \"#urgent\" para a consulta da pesquisa e incluirá apenas tópicos com a etiqueta ou categoria urgente." - tool_summary: - update_artifact: "Atualizar um artefato da Web" - create_artifact: "Criar artefato da web" - web_browser: "Navegar na Web" - github_search_files: "Arquivos de pesquisa do GitHub" - github_search_code: "Pesquisa de código do GitHub" - github_file_content: "Conteúdo de arquivo do GitHub" - github_pull_request_diff: "Diferença de solicitação pull do GitHub" - random_picker: "Seletor aleatório" - categories: "Listar categorias" - search: "Pesquisar" - tags: "Listar etiquetas" - time: "Hora" - summarize: "Resumir" - image: "Gerar imagem" - google: "Pesquisar no Google" - read: "Ler tópico" - setting_context: "Procurar contexto de configuração do site" - schema: "Procurar esquema de banco de dados" - search_settings: "Pesquisando configurações do site" - dall_e: "Gerar imagem" - search_meta_discourse: "Metapesquisa do Discourse" - javascript_evaluator: "Avaliar JavaScript" - tool_help: - update_artifact: "Atualizar um artefato da Web que usa o Bot de IA" - create_artifact: "Criar artefato da web usando o Bot de IA" - web_browser: "Navegar na página da Web com bot de IA" - github_search_code: "Procurar código no repositório do GitHub" - github_search_files: "Procurar arquivos no repositório do GitHub" - github_file_content: "Recuperar conteúdo de arquivos de um repositório do GitHub" - github_pull_request_diff: "Recuperar diferença de solicitação pull do GitHub" - random_picker: "Escolher um número ou elemento aleatório de uma lista" - categories: "Listar todas as categorias visíveis publicamente no fórum" - search: "Pesquisar todos os tópicos públicos no fórum" - tags: "Listar todas as etiquetas no fórum" - time: "Achar horário em vários fusos horários" - summary: "Resumir um tópico" - image: "Gerar imagem usando o Stable Diffusion" - google: "Pesquisar uma consulta no Google" - read: "Ler tópico público no fórum" - setting_context: "Procurar contexto de configuração do site" - schema: "Procurar esquema de banco de dados" - search_settings: "Pesquisar configurações do site" - dall_e: "Gerar imagem usando DALL-E 3" - search_meta_discourse: "Metapesquisa do Discourse" - javascript_evaluator: "Avaliar JavaScript" - tool_description: - update_artifact: "Artefato da Web que usa o Bot de IA atualizado" - web_browser: "Lendo %{url}" - github_search_files: "Pesquisa por \"%{keywords}\" em %{repo}/%{branch}" - github_search_code: "Pesquisa por \"%{query}\" em %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Conteúdo recuperado em %{file_paths} de %{repo_name}@%{branch}" - random_picker: "Escolhendo %{options}, escolha: %{result}" - read: "Lendo: %{title}" - time: "A hora em %{timezone} é %{time}" - summarize: "Resumo de %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "%{count} categoria encontrada" - other: "%{count} categorias encontrada" - tags: - one: "%{count} etiqueta encontrada" - other: "%{count} etiquetas encontradas" - search: - one: "%{count} resultado encontrado para \"%{query}\"" - other: "%{count} resultados encontrados para \"%{query}\"" - search_meta_discourse: - one: "%{count} resultado encontrado para \"%{query}\"" - other: "%{count} resultados encontrados para \"%{query}\"" - google: - one: "%{count} resultado encontrado para \"%{query}\"" - other: "%{count} resultados encontrados para \"%{query}\"" - setting_context: "Lendo contexto para: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "%{count} resultado encontrado para \"%{query}\"" - other: "%{count} resultados encontrados para \"%{query}\"" - summarization: - configuration_hint: - one: "Defina a configuração \"%{setting}\" primeiro." - other: "Defina estas configurações primeiro: %{settings}" - chat: - no_targets: "Não houve mensagens durante o período selecionado." - sentiment: - reports: - overall_sentiment: "Opinião geral (Positiva - Negativa)" - post_emotion: - sadness: "Tristeza \U0001F622" - surprise: "Surpresa \U0001F631" - neutral: "Neutro \U0001F610" - fear: "Medo \U0001F628" - anger: "Raiva \U0001F621" - joy: "Alegria \U0001F600" - disgust: "Repulsa \U0001F922" - sentiment_analysis: - positive: "Positivo" - negative: "Negativo" - neutral: "Neutro" - llm: - configuration: - disable_module_first: "Você precisa desativar %{setting} primeiro." - set_llm_first: "Defina %{setting} primeiro" - model_unreachable: "Não foi possível obter uma resposta deste modelo. Confira as configurações primeiro." - invalid_seeded_model: "Não é possível usar este modelo com este recurso" - must_select_model: "Selecione um LLM primeiro" - endpoints: - not_configured: "%{display_name} (não configurado)" - configuration_hint: - one: "Verifique se a configuração \"%{settings}\" foi definida." - other: "Verifique se estas configurações foram definidas: %{settings}" - delete_failed: - one: "Não foi possível excluir este modelo, pois está sendo usado por %{settings}. Atualize a configuração e tente novamente." - other: "Não foi possível excluir este modelo, pois está sendo usado por %{settings}. Atualize a configuração e tente novamente." - cannot_edit_builtin: "Não é possível eidtar um modelo integrado." - embeddings: - delete_failed: "Este modelo está em uso no momento. Atualize o modelo selecionado de incorporações de IA primeiro." - cannot_edit_builtin: "Não é possível editar um modelo integrado." - configuration: - disable_embeddings: "Você precisa desativar \"incorporações com IA ativadas\" primeiro." - choose_model: "Defina o \"modelo selecionado de incorporações com IA\" primeiro." - llm_models: - missing_provider_param: "%{param} Não pode ficar em branco" - bedrock_invalid_url: "Preencha todos os campos para usar este modelo." - ai_staff_action_logger: - updated: "atualizado(a)" - removed: "removido(a)" - errors: - quota_exceeded: "Você ultrapassou a cota deste modelo. Tente novamente em %{relative_time}." - quota_required: "Você deve especificar o máximo de tokens ou usos para este modelo" - no_query_specified: Requer parâmetro de consulta, especifique um. - no_user_for_persona: A persona especificada não tem usuário(a) associado(a). - persona_not_found: A persona especificada não existe. Confira os parâmetros persona_name ou persona_id - no_user_specified: Requer o parâmetro username ou user_unique_id. Insira-o. - user_not_found: O(a) usuário(a) especificado(a) não existe. Confira o parâmetro username. - persona_disabled: A persona especificada está desativada. Confira os parâmetros persona_name ou persona_id - no_default_llm: A persona deve ter default_llm definida. - user_not_allowed: O(a) usuário(a) não tem permissão para participar do tópico. - prompt_message_length: A mensagem %{idx} excede o limite de 1000 caracteres. diff --git a/config/locales/server.ro.yml b/config/locales/server.ro.yml deleted file mode 100644 index d297baa1..00000000 --- a/config/locales/server.ro.yml +++ /dev/null @@ -1,18 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ro: - reports: - overall_sentiment: - yaxis: "Dată" - discourse_ai: - ai_bot: - tool_summary: - search: "Căutare" - time: "Oră" - summarize: "Rezumat" - ai_staff_action_logger: - updated: "actualizat" diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml deleted file mode 100644 index b74d824f..00000000 --- a/config/locales/server.ru.yml +++ /dev/null @@ -1,463 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ru: - discourse_automation: - ai: - flag_types: - review: "Добавить публикацию в очередь проверки" - spam: "Пометить как спам и скрыть публикацию" - spam_silence: "Пометить как спам, скрыть публикацию и заморозить пользователя" - scriptables: - llm_triage: - title: Сортировка публикаций с помощью AI - description: "Сортировка публикаций с использованием большой языковой модели" - flagged_post: | -
Ответ модели:
-

%%LLM_RESPONSE%%

- Срабатывает по правилу: %%AUTOMATION_NAME%%. - llm_report: - title: Периодический отчет с использованием AI - description: "Периодический отчет на основе большой языковой модели" - site_settings: - discourse_ai_enabled: "Включить плагин AI для Discourse." - ai_toxicity_enabled: "Включить модуль проверки на токсичность." - ai_toxicity_inference_service_api_endpoint: "URL-адрес, по которому работает API для модуля проверки на токсичность" - ai_toxicity_inference_service_api_key: "API-ключ для API проверки на токсичность" - ai_toxicity_inference_service_api_model: "Модель, используемая для проверки. Многоязычная модель работает с итальянским, французским, русским, португальским, испанским и турецким языками." - ai_toxicity_flag_automatically: "Автоматически жаловаться на сообщения (публичные и в чате), которые превышают указанные пороговые значения." - ai_toxicity_flag_threshold_toxicity: "Токсичность: грубый, неуважительный или необоснованный комментарий, который может заставить вас покинуть обсуждение или удержаться от высказывания своей точки зрения." - ai_toxicity_flag_threshold_severe_toxicity: "Высокая токсичность: очень злобный, агрессивный или неуважительный комментарий, который наверняка заставит вас покинуть обсуждение или удержаться от высказывания своей точки зрения." - ai_toxicity_flag_threshold_obscene: "Непристойность" - ai_toxicity_flag_threshold_identity_attack: "Атака на личность" - ai_toxicity_flag_threshold_insult: "Оскорбление" - ai_toxicity_flag_threshold_threat: "Угроза" - ai_toxicity_flag_threshold_sexual_explicit: "Контент сексуального характера" - ai_toxicity_groups_bypass: "Сообщения пользователей в этих группах не будут оцениваться модулем проверки на токсичность." - ai_sentiment_enabled: "Включить модуль оценки настроения." - ai_sentiment_inference_service_api_endpoint: "URL-адрес, по которому работает API для модуля оценки настроения" - ai_sentiment_inference_service_api_key: "API-ключ для API оценки настроения" - ai_sentiment_models: "Модели, используемые для оценки. Настроение определяется как позитивное, нейтральное или негативное. Эмоции распределяются по следующим категориям: гнев, отвращение, страх, радость, нейтральность, печаль, удивление." - ai_nsfw_detection_enabled: "Включить модуль проверки на NSFW-контент." - ai_nsfw_inference_service_api_endpoint: "URL-адрес, по которому работает API для модуля проверки на NSFW-контент" - ai_nsfw_inference_service_api_key: "API-ключ для API проверки на NSFW-контент" - ai_nsfw_flag_automatically: "Автоматически жаловаться на публичные сообщения с NSFW-контентом, которые превышают указанные пороговые значения." - ai_nsfw_flag_threshold_general: "Общее пороговое значение, при котором изображение определяется как NSFW-контент." - ai_nsfw_flag_threshold_drawings: "Пороговое значение, при котором рисунок определяется как NSFW-контент." - ai_nsfw_flag_threshold_hentai: "Пороговое значение, при котором изображение, отнесенное к категории «хентай», определяется как NSFW-контент." - ai_nsfw_flag_threshold_porn: "Пороговое значение, при котором изображение, отнесенное к категории «порно», определяется как NSFW-контент." - ai_nsfw_flag_threshold_sexy: "Пороговое значение, при котором изображение, отнесенное к категории «сексуальное», определяется как NSFW-контент." - ai_nsfw_models: "Модели, используемые для оценки NSFW-контента." - ai_helper_enabled: "Включить AI-помощника" - composer_ai_helper_allowed_groups: "Для пользователей в этих группах будет отображаться кнопка AI-помощника в редакторе." - ai_helper_allowed_in_pm: "Включить AI-помощник для редактора в личных сообщениях." - ai_helper_model: "Модели, используемые для AI-помощника." - ai_helper_custom_prompts_allowed_groups: "Пользователи этих групп увидят опцию пользовательского запроса в AI- помощнике." - ai_helper_automatic_chat_thread_title_delay: "Задержка в минутах, прежде чем AI-помощник автоматически установит заголовок темы чата." - ai_helper_automatic_chat_thread_title: "Автоматически устанавливать заголовки тем чата в зависимости от содержимого темы." - ai_helper_illustrate_post_model: "Модель, которая будет использоваться для функции иллюстрации публикации AI-помощника для редактора" - ai_helper_enabled_features: "Выберите функции, которые нужно включить в AI-помощнике." - post_ai_helper_allowed_groups: "Группы пользователей, которым разрешен доступ к функциям AI-помощника в публикациях" - ai_helper_image_caption_model: "Выберите модель, которая будет использоваться для создания подписей к изображениям" - ai_auto_image_caption_allowed_groups: "Пользователи в этих группах могут включать автоматическое добавление подписей к изображениям." - ai_embeddings_selected_model: "Использовать выбранную модель для создания встраиваний." - ai_embeddings_generate_for_pms: "Генерировать векторные представления для личных сообщений." - ai_embeddings_semantic_related_topics_enabled: "Использовать семантический поиск для связанных тем." - ai_embeddings_semantic_related_topics: "Максимальное количество тем для показа в разделе связанных тем." - ai_embeddings_backfill_batch_size: "Количество векторных представлений для обратного заполнения каждые 15 минут." - ai_embeddings_semantic_search_enabled: "Включить полностраничный семантический поиск." - ai_embeddings_semantic_quick_search_enabled: "Включить опцию семантического поиска во всплывающем меню поиска." - ai_embeddings_semantic_related_include_closed_topics: "Включать в результаты семантического поиска закрытые темы" - ai_embeddings_semantic_search_hyde_model: "Модель, используемая для расширения ключевых слов, чтобы получить лучшие результаты во время семантического поиска" - ai_embeddings_per_post_enabled: Генерировать эмбеддинги для каждой публикации - ai_summarization_model: "Модель, используемая для формирования сводки" - ai_custom_summarization_allowed_groups: "Группы, которым разрешено делать новые сводки." - ai_pm_summarization_allowed_groups: "Группам разрешено создавать и просматривать сводки в личных сообщениях." - ai_summary_gists_enabled: "Автоматически создает краткие сводки последних ответов в темах" - ai_summary_gists_allowed_groups: "Группы, которые могут просматривать краткие сводки в списке горячих тем." - ai_summary_backfill_maximum_topics_per_hour: "Число сводок по теме для заполнения за час." - ai_bot_enabled: "Включить модуль AI-бота." - ai_bot_enable_chat_warning: "Показывать предупреждение при запуске личного чата. Можно переопределить путем редактирования строки перевода: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Если у GPT-бота есть доступ к личным сообщениям, он будет отвечать участникам указанных групп." - ai_bot_debugging_allowed_groups: "Разрешить этим группам видеть кнопку отладки в публикациях, которая отображает необработанный запрос и ответ AI." - ai_bot_public_sharing_allowed_groups: "Разрешить этим группам делиться личными сообщениями AI с общественностью по уникальной общедоступной ссылке. Примечание: если ваш сайт требует входа, для доступа также потребуется вход." - ai_bot_add_to_header: "Кнопка в заголовке для начала личного разговора с AI-ботом" - ai_bot_github_access_token: "Токен доступа к GitHub для использования с инструментами GitHub AI (требуется для поддержки поиска)" - ai_stability_api_key: "API-ключ для API stability.ai" - ai_stability_engine: "Движок генерации изображений для использования в API stability.ai" - ai_stability_api_url: "URL-адрес для API stability.ai" - ai_google_custom_search_api_key: "API-ключ для API программируемого поиска Google — см. https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX для API программируемого поиска Google" - reviewables: - reasons: - flagged_by_toxicity: Плагин AI определил этот контент как токсичный и отправил жалобу. - flagged_by_nsfw: Плагин AI определил как минимум одно из прикрепленных изображений как NSFW и отправил жалобу. - reports: - overall_sentiment: - title: "Общее настроение" - description: 'На диаграмме сравнивается количество публикаций, классифицированных как позитивные или негативные на основе того, превышают ли их оценки установленные пороговые значения. Нейтральные публикации и личные сообщения (ЛС) в расчет не включаются. Классификация выполнена с помощью модели "cardiffnlp/twitter-roberta-base-sentiment-latest"' - xaxis: "Позитивные(%)" - yaxis: "Дата" - emotion_admiration: - title: "\U0001F929 Восхищение" - description: "Публикации, классифицированные AI по эмоции «восхищение» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_amusement: - title: "\U0001F604 Веселье" - description: "Публикации, классифицированные AI по эмоции «веселье» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_anger: - title: "\U0001F620 Гнев" - description: "Публикации, классифицированные AI по эмоции «гнев» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_annoyance: - title: "\U0001F612 Недовольство" - description: "Публикации, классифицированные AI по эмоции «недовольство» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_approval: - title: "\U0001F44D Одобрение" - description: "Публикации, классифицированные AI по эмоции «одобрение» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_caring: - title: "\U0001F917 Забота" - description: "Публикации, классифицированные AI по эмоции «забота» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_confusion: - title: "\U0001F615 Замешательство" - description: "Публикации, классифицированные AI по эмоции «замешательство» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_curiosity: - title: "\U0001F914 Любопытство" - description: "Публикации, классифицированные AI по эмоции «любопытство» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_desire: - title: "\U0001F60D Восторг" - description: "Публикации, классифицированные AI по эмоции «восторг» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_disappointment: - title: "\U0001F61E Разочарование" - description: "Публикации, классифицированные AI по эмоции «разочарование» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_disapproval: - title: "\U0001F44E Неодобрение" - description: "Публикации, классифицированные AI по эмоции «неодобрение» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_disgust: - title: "\U0001F922 Отвращение" - description: "Публикации, классифицированные AI по эмоции «отвращение» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_embarrassment: - title: "\U0001F633 Смущение" - description: "Публикации, классифицированные AI по эмоции «смущение» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_excitement: - title: "\U0001F92A Волнение" - description: "Публикации, классифицированные AI по эмоции «волнение» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_fear: - title: "\U0001F628 Страх" - description: "Публикации, классифицированные AI по эмоции «страх» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_gratitude: - title: "\U0001F64F Благодарность" - description: "Публикации, классифицированные AI по эмоции «благодарность» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_grief: - title: "\U0001F622 Печаль" - description: "Публикации, классифицированные AI по эмоции «печаль» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_joy: - title: "\U0001F60A Радость" - description: "Публикации, классифицированные AI по эмоции «радость» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_love: - title: '❤️ Любовь' - description: "Публикации, классифицированные AI по эмоции «любовь» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_nervousness: - title: "\U0001F630 Тревога" - description: "Публикации, классифицированные AI по эмоции «тревога» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_neutral: - title: "\U0001F610 Нейтральность" - description: "Публикации, классифицированные AI по эмоции «нейтральность» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_optimism: - title: "\U0001F31F Оптимизм" - description: "Публикации, классифицированные AI по эмоции «оптимизм» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_pride: - title: "\U0001F981 Гордость" - description: "Публикации, классифицированные AI по эмоции «гордость» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_realization: - title: "\U0001F4A1 Озарение" - description: "Публикации, классифицированные AI по эмоции «озарение» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_relief: - title: "\U0001F60C Облегчение" - description: "Публикации, классифицированные AI по эмоции «облегчение» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_remorse: - title: "\U0001F614 Сожаление" - description: "Публикации, классифицированные AI по эмоции «сожаление» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_sadness: - title: "\U0001F62D Грусть" - description: "Публикации, классифицированные AI по эмоции «грусть» с помощью модели 'SamLowe/roberta-base-go_emotions'." - emotion_surprise: - title: "\U0001F632 Удивление" - description: "Публикации, классифицированные AI по эмоции «удивление» с помощью модели 'SamLowe/roberta-base-go_emotions'." - discourse_ai: - ai_artifact: - view_source: "Посмотреть источник" - view_changes: "Просмотреть изменения" - unknown_model: "Неизвестная модель AI" - tools: - custom_name: "%{name} (пользовательский)" - presets: - browse_web_jina: - name: "Просмотр веб-страниц (jina.ai)" - exchange_rate: - name: "Обменный курс" - stock_quote: - name: "Котировка акций (AlphaVantage)" - image_generation: - name: "Генератор изображений Flux (Together.ai)" - empty_tool: - name: "Начать с нуля..." - ai_helper: - errors: - completion_request_failed: "При попытке дать рекомендации произошла ошибка. Попробуйте еще раз." - prompts: - translate: Перевести на %{language} - generate_titles: Предложить названия для темы - proofread: Вычитать текст - markdown_table: Сгенерировать таблицу в формате Markdown - custom_prompt: "Пользовательский запрос" - explain: "Объяснить" - illustrate_post: "Проиллюстрировать публикацию" - replace_dates: "Умные даты" - painter: - attribution: - stable_diffusion_xl: "Изображение от Stable Diffusion XL" - dall_e_3: "Изображение от DALL-E 3" - image_caption: - attribution: "Подпись к изображению от AI" - share_ai: - read_more: "Прочитать полное содержимое" - onebox_title: "Разговор AI с пользователем %{llm_name}" - formatted_excerpt: "Разговор AI с пользователем %{llm_name}:\n %{excerpt}" - title: "%{title} — разговор AI — %{site_name}" - errors: - not_allowed: "Вы не можете поделиться этой темой" - other_people_in_pm: "Личные сообщения с другими людьми нельзя делать общедоступными" - other_content_in_pm: "Личные сообщения, содержащие публикации других людей, нельзя делать общедоступными" - failed_to_share: "Не удалось поделиться разговором" - conversation_deleted: "Общий доступ к разговору успешно удален" - spam_detection: - flag_reason: "Помечено как спам AI для Discourse" - silence_reason: "Пользователь автоматически заморожен AI для Discourse" - invalid_error_type: "Указан неверный тип ошибки" - unexpected: "Возникла непредвиденная ошибка" - bot_user_update_failed: "Не удалось обновить пользователя-бота для проверки спама" - ai_bot: - reply_error: "Извините, похоже, наша система столкнулась с неожиданной проблемой при попытке ответить.\n\n[details='Сведения об ошибке']\n%{details}\n[/details]" - default_pm_prefix: "[Личное сообщение от AI-бота без названия]" - personas: - default_llm_required: "Для включения чата требуется модель LLM по умолчанию" - cannot_delete_system_persona: "Системные персоны нельзя удалить, вместо этого отключите их" - cannot_edit_system_persona: "Системные персоны можно только переименовать, вы не можете редактировать инструменты или системный запрос, вместо этого отключите их и создайте копию" - github_helper: - name: "Помощник по GitHub" - description: "AI-бот, специализирующийся на оказании помощи в решении задач и вопросов, связанных с GitHub" - general: - name: Помощник по форуму - description: "AI-бот общего назначения, способный выполнять различные задачи." - artist: - name: Художник - description: "AI-бот, специализирующийся на генерации изображений" - sql_helper: - name: Помощник по SQL - description: "AI-бот, специализирующийся на создании SQL-запросов в этом экземпляре Discourse" - settings_explorer: - name: Обозреватель настроек - description: "AI-бот, специализирующийся на помощи в изучении настроек сайта Discourse" - creative: - name: Автор - description: "AI-бот без внешних интеграций, специализирующийся на творческих задачах" - dall_e3: - name: "DALL-E 3" - description: "AI-бот, специализирующийся на генерации изображений с использованием DALL-E 3" - discourse_helper: - name: "Помощник по Discourse" - description: "AI-бот, специализирующийся на решении задач, связанных с Discourse" - web_artifact_creator: - name: "Создатель веб-артефактов" - description: "AI-бот, специализирующийся на создании интерактивных веб-артефактов" - custom_prompt: - name: "Пользовательский запрос" - smart_dates: - name: "Умные даты" - topic_not_found: "Сводка недоступна: тема не найдена!" - summarizing: "Аналитик темы" - searching: "Поиск: '%{query}'" - tool_options: - researcher: - max_results: - name: "Максимальное количество результатов" - google: - base_query: - name: "Базовый поисковый запрос" - description: "Базовый запрос для использования при поиске. Примеры: 'site:example.com' будет включать только результаты с example.com, 'before:2022-01-01' будет включать только результаты с 2021 года и ранее. Этот текст добавляется к поисковому запросу." - read: - read_private: - name: "Читать приватные темы" - description: "Разрешить доступ ко всем темам, к которым есть доступ у пользователя (по умолчанию включены только публичные темы)" - search: - search_private: - name: "Поиск по приватным темам" - description: "Включить в результаты поиска все темы, к которым есть доступ у пользователя (по умолчанию включены только публичные темы)" - max_results: - name: "Максимальное количество результатов" - description: "Максимальное количество результатов, включаемых в поиск, — если будут использоваться пустые правила по умолчанию, а количество будет масштабироваться в зависимости от используемой модели. Максимальное значение — 100." - base_query: - name: "Базовый поисковый запрос" - description: "Базовый запрос, используемый при поиске. Пример: '#urgent' добавит '#urgent' к поисковому запросу и будет включать только темы со срочной категорией или тегом." - tool_summary: - update_artifact: "Обновить веб-артефакт" - create_artifact: "Создать веб-артефакт" - web_browser: "Просмотр веб-страниц" - github_search_files: "Поиск файлов на GitHub" - github_search_code: "Поиск кода на GitHub" - github_file_content: "Контент файла на GitHub" - github_pull_request_diff: "Сравнение изменений в запросе на вытягивание на GitHub" - random_picker: "Случайный выбор" - categories: "Вывод списка категорий" - search: "Искать" - tags: "Вывод списка тегов" - time: "Время" - summarize: "Сводка" - image: "Сгенерировать изображение" - google: "Искать в Google" - read: "Прочитать тему" - setting_context: "Поиск контекста настроек сайта" - schema: "Найти схему базы данных" - search_settings: "Поиск настроек сайта" - dall_e: "Сгенерировать изображение" - search_meta_discourse: "Поиск на Meta Discourse" - javascript_evaluator: "Оценить JavaScript" - tool_help: - update_artifact: "Обновить веб-артефакт с помощью AI-бота" - create_artifact: "Создать веб-артефакт с помощью AI-бота" - web_browser: "Просмотр веб-страницы с помощью AI-бота" - github_search_code: "Поиск кода в репозитории GitHub" - github_search_files: "Поиск файлов в репозитории GitHub" - github_file_content: "Извлечение контента файлов из репозитория на GitHub" - github_pull_request_diff: "Получить сравнение изменений в запросе на вытягивание на GitHub" - random_picker: "Выбирает случайное число или случайный элемент списка" - categories: "Вывод всех общедоступных категорий на форуме" - search: "Поиск по всем общедоступным темам на форуме" - tags: "Вывод всех тегов на форуме" - time: "Определить время в разных часовых поясах" - summary: "Сводка по теме" - image: "Генерировать изображение с помощью Stable Diffusion" - google: "Поиск в Google по запросу" - read: "Читать общедоступную тему на форуме" - setting_context: "Поиск контекста настроек сайта" - schema: "Найти схему базы данных" - search_settings: "Поиск по настройкам сайта" - dall_e: "Создать изображение с помощью DALL-E 3" - search_meta_discourse: "Поиск на Meta Discourse" - javascript_evaluator: "Оценить JavaScript" - tool_description: - update_artifact: "Обновлен веб-артефакт с помощью AI-бота" - web_browser: "Чтение: %{url}" - github_search_files: "Выполнен поиск «%{keywords}» в %{repo}/%{branch}" - github_search_code: "Выполнен поиск «%{query}» в %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "Получен контент файла %{file_paths} из репозитория %{repo_name}@%{branch}" - random_picker: "Выбор из %{options}, выбрано: %{result}" - read: "Чтение: %{title}" - time: "Время по часовому поясу %{timezone} — %{time}" - summarize: "Получена сводка: %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "Найдена %{count} категория" - few: "Найдены %{count} категории" - many: "Найдено %{count} категорий" - other: "Найдено %{count} категории" - tags: - one: "Найден %{count} тег" - few: "Найдено %{count} тега" - many: "Найдено %{count} тегов" - other: "Найдены %{count} тега" - search: - one: "Найден %{count} результат по запросу «%{query}»" - few: "Найдены %{count} результата по запросу «%{query}»" - many: "Найдено %{count} результатов по запросу «%{query}»" - other: "Найдено %{count} результата по запросу «%{query}»" - search_meta_discourse: - one: "Найден %{count} результат по запросу «%{query}»" - few: "Найдены %{count} результата по запросу «%{query}»" - many: "Найдено %{count} результатов по запросу «%{query}»" - other: "Найдено %{count} результата по запросу «%{query}»" - google: - one: "Найден %{count} результат по запросу «%{query}»" - few: "Найдены %{count} результата по запросу «%{query}»" - many: "Найдено %{count} результатов по запросу «%{query}»" - other: "Найдено %{count} результата по запросу «%{query}»" - setting_context: "Чтение контекста для: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "Найден %{count} результат по запросу «%{query}»" - few: "Найдено %{count} результата по запросу «%{query}»" - many: "Найдено %{count} результатов по запросу «%{query}»" - other: "Найдено %{count} результата по запросу «%{query}»" - summarization: - configuration_hint: - one: "Сначала настройте следующие параметры: %{settings}" - few: "Сначала настройте следующие параметры: %{settings}" - many: "Сначала настройте следующие параметры: %{settings}" - other: "Сначала настройте следующие параметры: %{settings}" - chat: - no_targets: "За выбранный период сообщений не было." - sentiment: - reports: - overall_sentiment: "Общее настроение (позитивное — негативное)" - post_emotion: - sadness: "Грусть \U0001F622" - surprise: "Удивление \U0001F631" - neutral: "Нейтральность \U0001F610" - fear: "Страх \U0001F628" - anger: "Гнев \U0001F621" - joy: "Радость \U0001F600" - disgust: "Отвращение \U0001F922" - sentiment_analysis: - positive: "Позитивное" - negative: "Негативное" - neutral: "Нейтральность" - llm: - configuration: - disable_module_first: "Сначала вам нужно отключить %{setting}." - set_llm_first: "Сначала задайте %{setting}" - model_unreachable: "Мы не смогли получить ответ от этой модели. Проверьте настройки." - invalid_seeded_model: "Эту модель нельзя использовать с этой функцией" - must_select_model: "Сначала вам нужно выбрать LLM" - endpoints: - not_configured: "%{display_name} (не настроено)" - configuration_hint: - one: "Убедитесь, что настроен параметр `%{settings}`." - few: "Убедитесь, что настроены параметры: `%{settings}`." - many: "Убедитесь, что настроены параметры: `%{settings}`." - other: "Убедитесь, что настроены параметры: `%{settings}`." - delete_failed: - one: "Мы не смогли удалить эту модель, потому что ее использует %{settings}. Обновите настройки и попробуйте еще раз." - few: "Мы не смогли удалить эту модель, потому что ее используют %{settings}. Обновите настройки и попробуйте еще раз." - many: "Мы не смогли удалить эту модель, потому что ее используют %{settings}. Обновите настройки и попробуйте еще раз." - other: "Мы не смогли удалить эту модель, потому что ее используют %{settings}. Обновите настройки и попробуйте еще раз." - cannot_edit_builtin: "Встроенную модель редактировать нельзя." - embeddings: - delete_failed: "Эта модель сейчас используется. Сначала обновите параметр `ai embeddings selected model`." - cannot_edit_builtin: "Встроенную модель редактировать нельзя." - configuration: - disable_embeddings: "Сначала вам необходимо отключить настройку 'ai embeddings enabled'." - choose_model: "Сначала задайте 'ai embeddings selected model'." - llm_models: - missing_provider_param: "Параметр %{param} не может быть пустым" - bedrock_invalid_url: "Чтобы использовать эту модель, заполните все поля." - ai_staff_action_logger: - updated: "обновлена" - removed: "отозваны" - errors: - quota_exceeded: "Вы превысили квоту для этой модели. Повторите попытку через %{relative_time}." - quota_required: "Необходимо указать максимальное количество токенов или использований для этой модели" - no_query_specified: Параметр запроса обязателен, укажите его. - no_user_for_persona: Указанная персона не имеет связанного с ней пользователя. - persona_not_found: Указанная персона не существует. Проверьте параметры persona_name или persona_id. - no_user_specified: Параметры user_unique_id или username обязательны, укажите нужное. - user_not_found: Указанный пользователь не существует. Проверьте параметр username. - persona_disabled: Указанная персона отключена. Проверьте параметры persona_name или persona_id. - no_default_llm: У персоны должен быть определен параметр default_llm. - user_not_allowed: Пользователю не разрешено участвовать в теме. - prompt_message_length: Сообщение %{idx} превышает ограничение в 1000 символов. diff --git a/config/locales/server.sk.yml b/config/locales/server.sk.yml deleted file mode 100644 index cf975e35..00000000 --- a/config/locales/server.sk.yml +++ /dev/null @@ -1,31 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sk: - reports: - overall_sentiment: - yaxis: "Dátum" - emotion_neutral: - title: "\U0001F610 Neutrálna" - discourse_ai: - ai_bot: - tool_summary: - search: "Hľadať" - tags: "Zoznam značiek" - time: "Čas" - summarize: "Zhrnúť" - summarization: - chat: - no_targets: "Počas vybraného obdobia neboli zaznamenané žiadne správy." - sentiment: - reports: - post_emotion: - neutral: "Neutrálna \U0001F610" - sentiment_analysis: - neutral: "Neutrálna" - ai_staff_action_logger: - updated: "aktualizované" - removed: "odstránené" diff --git a/config/locales/server.sl.yml b/config/locales/server.sl.yml deleted file mode 100644 index cda98ac1..00000000 --- a/config/locales/server.sl.yml +++ /dev/null @@ -1,15 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sl: - reports: - overall_sentiment: - yaxis: "Datum" - discourse_ai: - ai_bot: - tool_summary: - search: "Išči" - time: "Čas" diff --git a/config/locales/server.sq.yml b/config/locales/server.sq.yml deleted file mode 100644 index 09474d2c..00000000 --- a/config/locales/server.sq.yml +++ /dev/null @@ -1,14 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sq: - discourse_ai: - ai_bot: - tool_summary: - search: "Kërko" - time: "Koha" - ai_staff_action_logger: - removed: "u hoq" diff --git a/config/locales/server.sr.yml b/config/locales/server.sr.yml deleted file mode 100644 index b62eb641..00000000 --- a/config/locales/server.sr.yml +++ /dev/null @@ -1,12 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sr: - discourse_ai: - ai_bot: - tool_summary: - search: "Pretraži" - time: "Vreme" diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml deleted file mode 100644 index 2f215d4c..00000000 --- a/config/locales/server.sv.yml +++ /dev/null @@ -1,30 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sv: - reports: - overall_sentiment: - yaxis: "Datum" - emotion_neutral: - title: "\U0001F610 Neutralt" - discourse_ai: - ai_bot: - tool_summary: - search: "Sök" - time: "Tid" - summarize: "Sammanfatta" - summarization: - chat: - no_targets: "Det fanns inga meddelanden under den valda perioden." - sentiment: - reports: - post_emotion: - neutral: "Neutralt \U0001F610" - sentiment_analysis: - neutral: "Neutralt" - ai_staff_action_logger: - updated: "uppdaterade" - removed: "borttagen" diff --git a/config/locales/server.sw.yml b/config/locales/server.sw.yml deleted file mode 100644 index 87f11162..00000000 --- a/config/locales/server.sw.yml +++ /dev/null @@ -1,15 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -sw: - reports: - overall_sentiment: - yaxis: "Tarehe" - discourse_ai: - ai_bot: - tool_summary: - search: "Tafuta" - time: "Muda" diff --git a/config/locales/server.te.yml b/config/locales/server.te.yml deleted file mode 100644 index 3d4a0816..00000000 --- a/config/locales/server.te.yml +++ /dev/null @@ -1,26 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -te: - reports: - overall_sentiment: - yaxis: "తేదీ" - emotion_neutral: - title: "\U0001F610 తటస్థ" - discourse_ai: - ai_bot: - tool_summary: - search: "శోధించండి" - time: "కాలం" - sentiment: - reports: - post_emotion: - neutral: "తటస్థ \U0001F610" - sentiment_analysis: - neutral: "తటస్థ" - ai_staff_action_logger: - updated: "నవీకరించబడింది" - removed: "తీసివేయబడింది" diff --git a/config/locales/server.th.yml b/config/locales/server.th.yml deleted file mode 100644 index 60bd8d3a..00000000 --- a/config/locales/server.th.yml +++ /dev/null @@ -1,17 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -th: - reports: - overall_sentiment: - yaxis: "วันที่" - discourse_ai: - ai_bot: - tool_summary: - search: "ค้นหา" - time: "เวลา" - ai_staff_action_logger: - updated: "อัปเดตแล้ว" diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml deleted file mode 100644 index 8639baa0..00000000 --- a/config/locales/server.tr_TR.yml +++ /dev/null @@ -1,446 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -tr_TR: - discourse_automation: - ai: - flag_types: - review: "Gönderiyi inceleme kuyruğuna ekleyin" - spam: "Spam olarak işaretleyin ve gönderiyi gizleyin" - spam_silence: "Spam olarak işaretleyin, gönderiyi gizleyin ve kullanıcıyı susturun" - scriptables: - llm_triage: - title: AI kullanarak gönderileri triyajla - description: "Büyük bir dil modeli kullanarak gönderileri triyajla" - flagged_post: | -
Modelden gelen yanıt:
-

%%LLM_RESPONSE%%

- %%AUTOMATION_NAME%% kuralı ile tetiklenir. - llm_report: - title: AI kullanarak periyodik raporla - description: "Geniş bir dil modeline dayalı periyodik raporla" - site_settings: - discourse_ai_enabled: "Discourse YZ eklentisini etkinleştirin." - ai_toxicity_enabled: "Toksiklik modülünü etkinleştirin." - ai_toxicity_inference_service_api_endpoint: "API'nin toksiklik modülü için çalıştığı URL" - ai_toxicity_inference_service_api_key: "Toksiklik API'si için API anahtarı" - ai_toxicity_inference_service_api_model: "Çıkarım için kullanılacak model. Çok dilli model İtalyanca, Fransızca, Rusça, Portekizce, İspanyolca ve Türkçe ile çalışır." - ai_toxicity_flag_automatically: "Yapılandırılan eşiklerin üstündeki gönderilere / sohbet mesajlarına otomatik olarak bayrak ekleyin." - ai_toxicity_flag_threshold_toxicity: "Toksiklik: Bir tartışmayı bırakmanıza veya bakış açınızı paylaşmaktan vazgeçmenize neden olabilecek kaba, saygısız veya mantıksız bir yorum" - ai_toxicity_flag_threshold_severe_toxicity: "Şiddetli Toksiklik: Bir tartışmayı bırakmanıza veya bakış açınızı paylaşmaktan vazgeçmenize neden olabilecek çok nefret dolu, saldırgan veya saygısız bir yorum" - ai_toxicity_flag_threshold_obscene: "Müstehcen" - ai_toxicity_flag_threshold_identity_attack: "Kimlik Saldırısı" - ai_toxicity_flag_threshold_insult: "Hakaret" - ai_toxicity_flag_threshold_threat: "Tehdit" - ai_toxicity_flag_threshold_sexual_explicit: "Cinsel İçerikli" - ai_toxicity_groups_bypass: "Bu gruplardaki kullanıcıların gönderileri toksiklik modülü tarafından sınıflandırılmaz." - ai_sentiment_enabled: "Duyarlılık modülünü etkinleştirin." - ai_sentiment_inference_service_api_endpoint: "Duyarlılık modülü için API'nin çalıştığı URL" - ai_sentiment_inference_service_api_key: "Duyarlılık API'si için API anahtarı" - ai_sentiment_models: "Çıkarım için kullanılacak modeller. Duygu, gönderiyi pozitif/nötr/negatif alanda sınıflandırır. Duygu, öfke/iğrenme/korku/sevinç/nötr/üzüntü/şaşırma alanına göre sınıflandırır." - ai_nsfw_detection_enabled: "NSFW modülünü etkinleştirin." - ai_nsfw_inference_service_api_endpoint: "NSFW modülü için API'nin çalıştığı URL" - ai_nsfw_inference_service_api_key: "NSFW API'si için API anahtarı" - ai_nsfw_flag_automatically: "Yapılandırılan eşiklerin üstünde olan NSFW gönderileri otomatik olarak işaretleyin." - ai_nsfw_flag_threshold_general: "Bir görüntünün NSFW olarak değerlendirilmesi için Genel Eşik." - ai_nsfw_flag_threshold_drawings: "Bir çizimin NSFW olarak değerlendirilmesi için eşik." - ai_nsfw_flag_threshold_hentai: "Hentai olarak sınıflandırılan bir görüntünün NSFW olarak değerlendirilmesi için eşik." - ai_nsfw_flag_threshold_porn: "Porno olarak sınıflandırılan bir görüntünün NSFW olarak değerlendirilmesi için eşik." - ai_nsfw_flag_threshold_sexy: "Seksi olarak sınıflandırılan bir görüntünün NSFW olarak değerlendirilmesi için eşik." - ai_nsfw_models: "NSFW çıkarımı için kullanılacak modeller." - ai_helper_enabled: "YZ yardımcısını etkinleştirin." - composer_ai_helper_allowed_groups: "Bu gruplardaki kullanıcılar, bestecide YZ yardımcı düğmesini görür." - ai_helper_allowed_in_pm: "Kişisel mesajlarda bestecinin YZ yardımcıyı etkinleştirin." - ai_helper_model: "YZ yardımcı için kullanılacak model." - ai_helper_custom_prompts_allowed_groups: "Bu gruplardaki kullanıcılar AI yardımcısında özel istem seçeneğini görürler." - ai_helper_automatic_chat_thread_title_delay: "AI yardımcısının sohbet dizisi başlığını otomatik olarak ayarlamasından önceki dakika cinsinden gecikme." - ai_helper_automatic_chat_thread_title: "Sohbet konu başlıklarını konu içeriklerine göre otomatik olarak ayarlayın." - ai_helper_illustrate_post_model: "Besteci AI yardımcısının gönderiyi çizme özelliği için kullanılacak model" - ai_helper_enabled_features: "YZ yardımcısında etkinleştirilecek özellikleri seçin." - post_ai_helper_allowed_groups: "Gönderilerde YZ Yardımcısı özelliklerine erişmesine izin verilen kullanıcı grupları" - ai_helper_image_caption_model: "Görüntü alt yazıları oluşturmak için kullanılacak modeli seçin" - ai_auto_image_caption_allowed_groups: "Bu gruplardaki kullanıcılar otomatik görüntü alt yazısı eklemeyi değiştirebilir." - ai_embeddings_selected_model: "Gömme oluşturmak için seçili modeli kullanın." - ai_embeddings_generate_for_pms: "Kişisel mesajlar için gömmeler oluşturun." - ai_embeddings_semantic_related_topics_enabled: "İlgili konular için Semantik Arama'yı kullanın." - ai_embeddings_semantic_related_topics: "İlgili konu bölümünde gösterilecek maksimum konu sayısı." - ai_embeddings_backfill_batch_size: "Her 15 dakikada bir geri doldurulacak gömme sayısı." - ai_embeddings_semantic_search_enabled: "Tam sayfa semantik aramayı etkinleştirin." - ai_embeddings_semantic_quick_search_enabled: "Arama menüsü açılır penceresinde semantik arama seçeneğini etkinleştirin." - ai_embeddings_semantic_related_include_closed_topics: "Kapalı konuları semantik arama sonuçlarına dâhil edin" - ai_embeddings_semantic_search_hyde_model: "Anlamsal arama sırasında daha iyi sonuçlar elde etmek için anahtar kelimeleri genişletmek üzere kullanılan model" - ai_embeddings_per_post_enabled: Her gönderi için yerleştirmeler oluşturun - ai_summarization_model: "Özetleme için kullanılacak model" - ai_custom_summarization_allowed_groups: "Grupların yeni özetler oluşturmasına izin verilir." - ai_pm_summarization_allowed_groups: "Kişisel mesajlarda özet oluşturma ve görüntüleme yetkisi olan gruplar." - ai_summary_gists_enabled: "Konulardaki son yanıtların kısa özetlerini otomatik olarak oluşturun" - ai_summary_gists_allowed_groups: "Popüler konular listesindeki özetleri görme yetkisine sahip gruplar." - ai_summary_backfill_maximum_topics_per_hour: "Saat başına doldurulacak konu özeti sayısı." - ai_bot_enabled: "YZ Botu modülünü etkinleştirin." - ai_bot_enable_chat_warning: "Kişisel mesaj sohbeti başlatıldığında bir uyarı görüntüleyin. Çeviri dizesi düzenlenerek geçersiz kılınabilir: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "GPT Botunun Kişisel Mesaja erişimi olduğunda, bu grupların üyelerine yanıt verir." - ai_bot_debugging_allowed_groups: "Bu grupların gönderilerde ham YZ isteğini ve yanıtını görüntüleyen bir hata ayıklama düğmesi görmesine izin verin" - ai_bot_public_sharing_allowed_groups: "Bu grupların, herkese açık benzersiz bir bağlantı aracılığıyla YZ kişisel mesajlarını herkese açık şekilde paylaşmasına izin verin. Not: Siteniz giriş yapılmasını gerektiriyorsa paylaşımlar da giriş yapılmasını gerektirir." - ai_bot_add_to_header: "Bir YZ Botu ile bir kişisel mesaj başlatmak için başlıkta bir düğme gösterin" - ai_bot_github_access_token: "GitHub AI araçlarıyla kullanım için GitHub erişim token'ı (arama desteği için gereklidir)" - ai_stability_api_key: "stability.ai API'si için API anahtarı" - ai_stability_engine: "stability.ai API'si için kullanılacak görüntü oluşturma motoru" - ai_stability_api_url: "stability.ai API'si için URL" - ai_google_custom_search_api_key: "Google Özel Arama API'sı için API anahtarı bkz.: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "Google Özel Arama API'sı için CX" - reviewables: - reasons: - flagged_by_toxicity: YZ eklentisi, toksik olarak sınıflandırdıktan sonra buna bayrak ekledi. - flagged_by_nsfw: YZ eklentisi, eklenen resimlerden en az birini NSFW olarak sınıflandırdıktan sonra buna bayrak ekledi. - reports: - overall_sentiment: - title: "Genel duyarlılık" - description: 'Grafik, olumlu veya olumsuz olarak sınıflandırılan gönderilerin sayısını karşılaştırır. Bunlar, pozitif veya negatif puanlar belirlenen eşik puanından fazla olduğunda hesaplanır. Bu, nötr gönderilerin gösterilmediği anlamına gelir. Kişisel mesajlar da hariç tutulmuştur. "cardiffnlp/twitter-roberta-base-sentiment-latest" ile sınıflandırılır' - xaxis: "Pozitif(%)" - yaxis: "Tarih" - emotion_admiration: - title: "\U0001F929 Hayranlık" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla hayranlık duygusu ile sınıflandırılan gönderiler." - emotion_amusement: - title: "\U0001F604 Eğlenme" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla eğlenme duygusu ile sınıflandırılan gönderiler." - emotion_anger: - title: "\U0001F620 Öfke" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla öfke duygusu ile sınıflandırılan gönderiler." - emotion_annoyance: - title: "\U0001F612 Rahatsız olma" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla rahatsız olma duygusu ile sınıflandırılan gönderiler." - emotion_approval: - title: "\U0001F44D Onaylama" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla onaylama duygusu ile sınıflandırılan gönderiler." - emotion_caring: - title: "\U0001F917 Önemseme" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla önemseme duygusu ile sınıflandırılan gönderiler." - emotion_confusion: - title: "\U0001F615 Kafa karışıklığı" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla kafa karışıklığı duygusu ile sınıflandırılan gönderiler." - emotion_curiosity: - title: "\U0001F914 Merak" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla merak duygusu ile sınıflandırılan gönderiler." - emotion_desire: - title: "\U0001F60D Arzu" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla arzu duygusu ile sınıflandırılan gönderiler." - emotion_disappointment: - title: "\U0001F61E Hayal kırıklığı" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla hayal kırıklığı duygusu ile sınıflandırılan gönderiler." - emotion_disapproval: - title: "\U0001F44E Onaylamama" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla onaylamama duygusu ile sınıflandırılan gönderiler." - emotion_disgust: - title: "\U0001F922 İğrenme" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla iğrenme duygusu ile sınıflandırılan gönderiler." - emotion_embarrassment: - title: "\U0001F633 Utanma" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla utanma duygusu ile sınıflandırılan gönderiler." - emotion_excitement: - title: "\U0001F92A Heyecan" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla heyecan duygusu ile sınıflandırılan gönderiler." - emotion_fear: - title: "\U0001F628 Korku" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla korku duygusu ile sınıflandırılan gönderiler." - emotion_gratitude: - title: "\U0001F64F Minnettarlık" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla minnettarlık duygusu ile sınıflandırılan gönderiler." - emotion_grief: - title: "\U0001F622 Keder" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla keder duygusu ile sınıflandırılan gönderiler." - emotion_joy: - title: "\U0001F60A Neşe" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla neşe duygusu ile sınıflandırılan gönderiler." - emotion_love: - title: '❤️ Sevgi' - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla sevgi duygusu ile sınıflandırılan gönderiler." - emotion_nervousness: - title: "\U0001F630 Gerginlik" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla gerginlik duygusu ile sınıflandırılan gönderiler." - emotion_neutral: - title: "\U0001F610 Nötr" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla nötr duygusu ile sınıflandırılan gönderiler." - emotion_optimism: - title: "\U0001F31F İyimserlik" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla iyimserlik duygusu ile sınıflandırılan gönderiler." - emotion_pride: - title: "\U0001F981 Gurur" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla gurur duygusu ile sınıflandırılan gönderiler." - emotion_realization: - title: "\U0001F4A1 Farkına varma" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla farkına varma duygusu ile sınıflandırılan gönderiler." - emotion_relief: - title: "\U0001F60C Rahatlama" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla rahatlama duygusu ile sınıflandırılan gönderiler." - emotion_remorse: - title: "\U0001F614 Pişmanlık" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla pişmanlık duygusu ile sınıflandırılan gönderiler." - emotion_sadness: - title: "\U0001F62D Üzüntü" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla üzüntü duygusu ile sınıflandırılan gönderiler." - emotion_surprise: - title: "\U0001F632 Şaşırma" - description: "\"SamLowe/roberta-base-go_emotions\" modeli kullanılarak YZ aracılığıyla şaşırma duygusu ile sınıflandırılan gönderiler." - discourse_ai: - ai_artifact: - view_source: "Kaynağı görüntüle" - view_changes: "Değişiklikleri görüntüle" - unknown_model: "Bilinmeyen YZ modeli" - tools: - custom_name: "%{name} (özel)" - presets: - browse_web_jina: - name: "Web'de gezinin (jina.ai)" - exchange_rate: - name: "Döviz kuru" - stock_quote: - name: "Hisse senedi fiyat teklifi (AlphaVantage)" - image_generation: - name: "Flux görüntü oluşturucu (Together.ai)" - empty_tool: - name: "Boştan başla..." - ai_helper: - errors: - completion_request_failed: "Öneriler sunulmaya çalışılırken bir şeyler ters gitti. Lütfen tekrar deneyin." - prompts: - translate: '%{language} diline çevir' - generate_titles: Konu başlıkları öner - proofread: Yazım hataları düzeltilmiş metin - markdown_table: Markdown tablosu oluştur - custom_prompt: "Özel İstem" - explain: "Açıkla" - illustrate_post: "Gönderiyi göster" - replace_dates: "Akıllı tarihler" - painter: - attribution: - stable_diffusion_xl: "Stable Diffusion XL'den görüntü" - dall_e_3: "DALL-E 3'den görüntü" - image_caption: - attribution: "AI tarafından altyazılı" - share_ai: - read_more: "Tam metni okuyun" - onebox_title: "%{llm_name} ile YZ Konuşması" - formatted_excerpt: "%{llm_name} ile YZ Konuşması:\n %{excerpt}" - title: "%{title} - YZ Konuşması - %{site_name}" - errors: - not_allowed: "Bu konuyu paylaşmanıza izin verilmiyor" - other_people_in_pm: "Diğer insanlarla olan kişisel mesajlar herkese açık olarak paylaşılamaz" - other_content_in_pm: "Başkalarına ait gönderileri içeren kişisel mesajlar herkese açık olarak paylaşılamaz" - failed_to_share: "Konuşma paylaşılamadı" - conversation_deleted: "Konuşma paylaşımı başarıyla silindi" - spam_detection: - flag_reason: "Discourse AI tarafından istenmeyen içerik olarak bayrak eklendi" - silence_reason: "Kullanıcı Discourse AI tarafından otomatik olarak susturuldu" - invalid_error_type: "Geçersiz hata türü sağlandı" - unexpected: "Beklenmeyen bir hata oluştu" - bot_user_update_failed: "İstenmeyen içerik tarama botu kullanıcısı güncellenemedi" - ai_bot: - reply_error: "Üzgünüz, sistemimiz yanıtlamaya çalışırken beklenmeyen bir sorunla karşılaşmış gibi görünüyor.\n\n[details='Hata ayrıntıları']\n%{details}\n[/details]" - default_pm_prefix: "[Adsız YZ botu kişisel mesajı]" - personas: - default_llm_required: "Sohbeti etkinleştirmeden önce varsayılan LLM modeli gereklidir" - cannot_delete_system_persona: "Sistem kişilikleri silinemez, lütfen bunun yerine devre dışı bırakın" - cannot_edit_system_persona: "Sistem kişileri yalnızca yeniden adlandırılabilir, araçları veya sistem istemini düzenleyemezsiniz, bunun yerine devre dışı bırakıp bir kopyasını oluşturabilirsiniz" - github_helper: - name: "GitHub Yardımcısı" - description: "GitHub ile ilgili görevlere ve sorulara yardımcı olma konusunda uzmanlaşmış YZ Botu" - general: - name: Forum Yardımcısı - description: "Çeşitli görevleri yerine getirebilen genel amaçlı AI Botu" - artist: - name: Sanatçı - description: "Görsel üretme konusunda uzmanlaşmış AI Bot" - sql_helper: - name: SQL Yardımcısı - description: "Bu Discourse örneğinde SQL sorguları oluşturmaya yardımcı olma konusunda uzmanlaşmış AI Bot" - settings_explorer: - name: Ayarlar Gezgini - description: "Discourse site ayarlarını keşfetmeye yardımcı olma konusunda uzmanlaşmış AI Bot" - creative: - name: Yaratıcı - description: "Yaratıcı görevlerde uzmanlaşmış, harici entegrasyonları olmayan AI Bot" - dall_e3: - name: "DALL-E 3" - description: "DALL-E 3 kullanarak görüntü üretme konusunda uzmanlaşmış AI Bot" - discourse_helper: - name: "Discourse Yardımcısı" - description: "Discourse ile ilgili görevlere yardımcı olma konusunda uzmanlaşmış YZ Botu" - web_artifact_creator: - name: "Web Artifact'i Oluşturucu" - description: "Etkileşimli web artifact'leri oluşturma konusunda uzmanlaşmış YZ Botu" - custom_prompt: - name: "Özel istem" - smart_dates: - name: "Akıllı tarihler" - topic_not_found: "Özet mevcut değil, konu bulunamadı!" - summarizing: "Konu özetleniyor" - searching: "Aranıyor: '%{query}'" - tool_options: - researcher: - max_results: - name: "Maksimum sonuç sayısı" - google: - base_query: - name: "Temel Arama Sorgusu" - description: "Arama yaparken kullanılacak temel sorgu. Örnekler: \"site:example.com\" yalnızca example.com adresindeki sonuçları içerir, before:2022-01-01 yalnızca 2021 ve öncesindeki sonuçları içerir. Bu metin, arama sorgusuna eklenir." - read: - read_private: - name: "Özel oku" - description: "Kullanıcının erişebildiği tüm konulara erişim izni ver (varsayılan olarak yalnızca herkese açık konular dâhil edilir)" - search: - search_private: - name: "Özel ara" - description: "Kullanıcının erişebildiği tüm konuları arama sonuçlarına dâhil et (varsayılan olarak yalnızca herkese açık konular dâhil edilir)" - max_results: - name: "Maksimum sonuç sayısı" - description: "Aramaya dahil edilecek maksimum sonuç sayısı; boşsa varsayılan kurallar kullanılır ve kullanılan modele bağlı olarak sayım ölçeklendirilir. En yüksek değer 100'dür." - base_query: - name: "Temel Arama Sorgusu" - description: "Arama yaparken kullanılacak temel sorgu. Örnek: \"#acil\" arama sorgusuna \"#acil\" iadesini ekler ve yalnızca acil kategorisine veya etiketine sahip konuları içerir." - tool_summary: - update_artifact: "Web artifact'i güncelle" - create_artifact: "Web artifact'i oluştur" - web_browser: "Web'de gezinin" - github_search_files: "GitHub arama dosyaları" - github_search_code: "GitHub kodu arama" - github_file_content: "GitHub dosya içeriği" - github_pull_request_diff: "GitHub çekme isteği farkı" - random_picker: "Rastgele Seçici" - categories: "Kategorileri listele" - search: "Ara" - tags: "Etiketleri listele" - time: "Saat" - summarize: "Özetle" - image: "Görüntü oluştur" - google: "Google'da ara" - read: "Konuyu oku" - setting_context: "Site ayarı bağlamına bak" - schema: "Veri tabanı şemasına bak" - search_settings: "Site ayarları aranıyor" - dall_e: "Görüntü oluştur" - search_meta_discourse: "Meta Discourse'ta arama yapın" - javascript_evaluator: "JavaScript'i değerlendirin" - tool_help: - update_artifact: "YZ Botunu kullanarak bir web artifact'i güncelle" - create_artifact: "YZ Botunu kullanarak bir web artifact'i oluştur" - web_browser: "YZ Botunu kullanarak web sayfasına göz atın" - github_search_code: "GitHub deposunda kod arayın" - github_search_files: "GitHub deposunda dosya arayın" - github_file_content: "Bir GitHub deposundan dosyaların içeriğini alın" - github_pull_request_diff: "GitHub çekme isteği farkını alın" - random_picker: "Rastgele bir sayı veya listenin rastgele bir ögesini seçin" - categories: "Forumdaki herkese açık tüm kategorileri listeleyin" - search: "Forumdaki tüm herkese açık konuları arayın" - tags: "Forumdaki tüm etiketleri listeleyin" - time: "Çeşitli saat dilimlerinde zamanı bulun" - summary: "Bir konuyu özetleyin" - image: "Stable Diffusion kullanarak görüntü oluşturun" - google: "Bir sorgu için Google'da arama yapın" - read: "Forumdaki herkese açık konuyu okuyun" - setting_context: "Site ayarı bağlamına bakın" - schema: "Veri tabanı şemasına bakın" - search_settings: "Site ayarlarında arama yapın" - dall_e: "DALL-E 3 kullanarak görüntü oluşturun" - search_meta_discourse: "Meta Discourse'ta arama yapın" - javascript_evaluator: "JavaScript'i değerlendirin" - tool_description: - update_artifact: "YZ Botunu kullanarak bir web artifact'i güncellendi" - web_browser: "Okunuyor: %{url}" - github_search_files: "%{repo}/%{branch} içinde \"%{keywords}\" arandı" - github_search_code: "%{repo} içinde \"%{query}\" arandı" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "%{file_paths} içeriği %{repo_name}@%{branch} kaynağından alındı" - random_picker: "%{options} adresinden seçiliyor, seçilen: %{result}" - read: "Okunuyor: %{title}" - time: "%{timezone} saat diliminde saat %{time}" - summarize: "%{title} özetlendi" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "%{count} kategori bulundu" - other: "%{count} kategori bulundu" - tags: - one: "%{count} etiket bulundu" - other: "%{count} etiket bulundu" - search: - one: "\"%{query}\" için %{count} sonuç bulundu" - other: "\"%{query}\" için %{count} sonuç bulundu" - search_meta_discourse: - one: "\"%{query}\" için %{count} sonuç bulundu" - other: "\"%{query}\" için %{count} sonuç bulundu" - google: - one: "\"%{query}\" için %{count} sonuç bulundu" - other: "\"%{query}\" için %{count} sonuç bulundu" - setting_context: "Şunun için bağlam okunuyor: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "'%{query}' için %{count} sonuç bulundu" - other: "'%{query}' için %{count} sonuç bulundu" - summarization: - configuration_hint: - one: "Önce `%{setting}` ayarını yapılandırın" - other: "Önce şu ayarları yapılandırın: %{settings}" - chat: - no_targets: "Seçilen dönemde mesaj yok." - sentiment: - reports: - overall_sentiment: "Genel duygu (Olumlu - Olumsuz)" - post_emotion: - sadness: "Üzüntü \U0001F622" - surprise: "Sürpriz \U0001F631" - neutral: "Nötr \U0001F610" - fear: "Korku \U0001F628" - anger: "Öfke \U0001F621" - joy: "Neşe \U0001F600" - disgust: "İğrenme \U0001F922" - sentiment_analysis: - positive: "Pozitif" - negative: "Negatif" - neutral: "Nötr" - llm: - configuration: - disable_module_first: "Önce %{setting} devre dışı bırakılmalı." - set_llm_first: "Önce %{setting} ayarlanmalı" - model_unreachable: "Bu modelden yanıt alamadık. Önce ayarlarınızı kontrol edin." - invalid_seeded_model: "Bu modeli bu özellikle kullanamazsınız" - must_select_model: "Öncelikle bir LLM seçmelisiniz" - endpoints: - not_configured: "%{display_name} (yapılandırılmadı)" - configuration_hint: - one: "`%{settings}` ayarının yapılandırıldığından emin olun." - other: "Şu ayarların yapılandırıldığından emin olun: %{settings}" - delete_failed: - one: "%{settings} kullandığı için bu modeli silemedik. Ayarı güncelleyin ve tekrar deneyin." - other: "%{settings} kullandığı için bu modeli silemedik. Ayarları güncelleyip tekrar deneyin." - cannot_edit_builtin: "Yerleşik bir modeli düzenleyemezsiniz." - embeddings: - delete_failed: "Bu model şu anda kullanımda. Öncelikle `ai embeddings selected model`i güncelleyin." - cannot_edit_builtin: "Yerleşik bir modeli düzenleyemezsiniz." - configuration: - disable_embeddings: "Önce \"yz yerleştirmeleri etkin\" seçeneğini devre dışı bırakmanız gerekiyor." - invalid_config: "Geçersiz bir seçenek seçtiniz." - choose_model: "Önce 'ai embeddings selected model'i ayarlayın." - llm_models: - missing_provider_param: "%{param} boş olamaz" - bedrock_invalid_url: "Bu modeli kullanmak için lütfen tüm alanları doldurun." - ai_staff_action_logger: - updated: "güncellendi" - removed: "kaldırıldı" - errors: - quota_exceeded: "Bu model için kotayı aştınız. Lütfen %{relative_time} içinde tekrar deneyin." - quota_required: "Bu model için maksimum token veya kullanımları belirtmelisiniz" - no_query_specified: Sorgu parametresi gerekli, lütfen belirtin. - no_user_for_persona: Belirtilen kişilik ile ilişkilendirilmiş bir kullanıcı yok. - persona_not_found: Belirtilen kişilik mevcut değil. persona_name veya persona_id parametrelerini kontrol edin. - no_user_specified: Kullanıcı adı veya user_unique_id parametresi gerekli, lütfen belirtin. - user_not_found: Belirtilen kullanıcı mevcut değil. Kullanıcı adı parametresini kontrol edin. - persona_disabled: Belirtilen kişilik devre dışı. persona_name veya persona_id parametrelerini kontrol edin. - no_default_llm: Kişiliğin tanımlanmış bir default_llm'si olmalıdır. - user_not_allowed: Kullanıcının konuya katılmasına izin verilmiyor. - prompt_message_length: '%{idx} mesajı 1000 karakter limitinin üzerinde.' diff --git a/config/locales/server.ug.yml b/config/locales/server.ug.yml deleted file mode 100644 index 4fbeb22b..00000000 --- a/config/locales/server.ug.yml +++ /dev/null @@ -1,31 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ug: - reports: - overall_sentiment: - yaxis: "چېسلا" - emotion_neutral: - title: "\U0001F610 بىتەرەپ" - discourse_ai: - ai_bot: - tool_summary: - search: "ئىزدە" - tags: "بەلگە تىزىمىنى كۆرسىتىدۇ" - time: "ۋاقىت" - summarize: "خۇلاسە" - summarization: - chat: - no_targets: "تاللىغان مەزگىلدىكى ئۇچۇر يوق." - sentiment: - reports: - post_emotion: - neutral: "بىتەرەپ \U0001F610" - sentiment_analysis: - neutral: "بىتەرەپ" - ai_staff_action_logger: - updated: "يېڭىلاندى" - removed: "چىقىرىۋېتىلدى" diff --git a/config/locales/server.uk.yml b/config/locales/server.uk.yml deleted file mode 100644 index 0618fd8c..00000000 --- a/config/locales/server.uk.yml +++ /dev/null @@ -1,354 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -uk: - discourse_automation: - ai: - flag_types: - review: "Додати повідомлення до черги рецензування" - spam: "Позначити як спам і приховати повідомлення" - scriptables: - llm_triage: - title: Сортування повідомлень за допомогою ШІ - description: "Сортування повідомлень за допомогою великої мовної моделі" - llm_report: - title: Періодичний звіт з використанням ШІ - description: "Періодичний звіт на основі великої мовної моделі" - site_settings: - discourse_ai_enabled: "Увімкнкти плагін ШІ для Discourse" - ai_toxicity_enabled: "Увімкнути модуль перевірки на токсичність" - ai_toxicity_inference_service_api_endpoint: "URL-адреса, де працює API для модуля перевірки на токсичність" - ai_toxicity_inference_service_api_key: "Ключ API для API токсичності" - ai_toxicity_inference_service_api_model: "Модель для перевірки. Багатомовна модель працює з італійською, французькою, російською, португальською, іспанською та турецькою мовами." - ai_toxicity_flag_automatically: "Автоматично позначати публікації/повідомлення чату, які перевищують налаштовані порогові значення." - ai_toxicity_flag_threshold_toxicity: "Токсичність: грубий, неповажний або необґрунтований коментар, який з певною ймовірністю змусить вас вийти з дискусії або відмовитися від обміну думками." - ai_toxicity_flag_threshold_severe_toxicity: "Сильна токсичність: дуже ненависний, агресивний або неповажний коментар, який з великою ймовірністю змусить вас вийти з дискусії або відмовитися від обміну думками." - ai_toxicity_flag_threshold_obscene: "Непристойно." - ai_toxicity_flag_threshold_identity_attack: "Атака на особистість" - ai_toxicity_flag_threshold_insult: "Образа" - ai_toxicity_flag_threshold_threat: "Погроза" - ai_toxicity_flag_threshold_sexual_explicit: "Відверто сексуальний" - ai_toxicity_groups_bypass: "Користувачі цих груп не матимуть можливості класифікувати свої дописи за модулем токсичності." - ai_sentiment_enabled: "Увімкніть модуль настроїв." - ai_sentiment_inference_service_api_endpoint: "URL-адреса, на якій запущено API для модуля настрою" - ai_sentiment_inference_service_api_key: "Ключ API для API настрою" - ai_sentiment_models: "Моделі, які можна використовувати для висновків. Почуття класифікує пост у просторі позитивного/нейтрального/негативного. Емоції розпорділяються за категоріями гніву/відрази/страху/радості/нейтральності/суму/подиву." - ai_nsfw_detection_enabled: "Увімкнути модуль перевірки NSFW." - ai_nsfw_inference_service_api_endpoint: "URL-адреса, на якій запущено API для модуля NSFW" - ai_nsfw_inference_service_api_key: "Ключ API для API NSFW" - ai_nsfw_flag_automatically: "Автоматично позначати публікації NSFW, які перевищують налаштовані порогові значення." - ai_nsfw_flag_threshold_general: "Загальне порогове значення для зображення, яке буде вважатися NSFW." - ai_nsfw_flag_threshold_drawings: "Поріг, за яким малюнок вважається NSFW." - ai_nsfw_flag_threshold_hentai: "Поріг для зображення, класифікованого як хентай, щоб вважатися NSFW." - ai_nsfw_flag_threshold_porn: "Поріг для зображення, яке класифікується як порно, щоб вважатися NSFW." - ai_nsfw_flag_threshold_sexy: "Поріг для зображення, класифікованого як сексуальне, щоб вважатися NSFW." - ai_nsfw_models: "Моделі для використання NSFW умов." - ai_helper_enabled: "Увімкнути помічника ШІ." - composer_ai_helper_allowed_groups: "Користувачі цих груп бачитимуть кнопку помічника ШІ в композиторі." - ai_helper_allowed_in_pm: "Увімкнути Помічник ШІ в особистих повідомленнях." - ai_helper_model: "Модель для помічника ШІ." - ai_helper_custom_prompts_allowed_groups: "Користувачі з цих груп побачать опцію користувацьких підказок у помічнику ШІ." - ai_helper_automatic_chat_thread_title_delay: "Затримка в хвилинах перед тим, як ШІ-помічник автоматично встановить заголовок теми чату." - ai_helper_automatic_chat_thread_title: "Автоматично встановлювати заголовки тем чату на основі вмісту теми." - ai_helper_illustrate_post_model: "Модель, яка використовується для ілюстративної функції помічника композитора зі ШІ" - ai_helper_enabled_features: "Оберіть функції для активації в ШІ-помічнику." - post_ai_helper_allowed_groups: "Групи користувачів, яким дозволено доступ до функцій ШІ-помічника в публікаціях" - ai_helper_image_caption_model: "Виберіть модель для генерації підписів до зображень" - ai_auto_image_caption_allowed_groups: "Користувачі в цих групах можуть вмикати автоматичне додавання підписів до зображень." - ai_embeddings_generate_for_pms: "Створюйте вбудовування для особистих повідомлень." - ai_embeddings_semantic_related_topics_enabled: "Використовуйте семантичний пошук для пов’язаних тем." - ai_embeddings_semantic_related_topics: "Максимальна кількість тем для показу в розділі пов’язаних тем." - ai_embeddings_backfill_batch_size: "Кількість закладок для заповнення кожні 15 хвилин." - ai_embeddings_semantic_search_enabled: "Увімкнути повносторінковий семантичний пошук." - ai_embeddings_semantic_quick_search_enabled: "Увімкнути опцію семантичного пошуку у спливаючому меню пошуку." - ai_embeddings_semantic_related_include_closed_topics: "Включити закриті теми в результати семантичного пошуку" - ai_embeddings_semantic_search_hyde_model: "Модель, що використовується для розширення ключових слів для отримання кращих результатів під час семантичного пошуку" - ai_embeddings_per_post_enabled: Згенерувати вкладення для кожного повідомлення - ai_summarization_model: "Модель для узагальнення" - ai_custom_summarization_allowed_groups: "Групи, яким дозволено створювати нові зведення." - ai_pm_summarization_allowed_groups: "Групи дозволили створювати та переглядати підсумки в особистих повідомленнях." - ai_summary_gists_allowed_groups: "Групи, яким дозволено бачити суть у списку гарячих тем." - ai_summary_backfill_maximum_topics_per_hour: "Кількість підсумків тем для заповнення на годину." - ai_bot_enabled: "Увімкніть модуль AI Bot." - ai_bot_enable_chat_warning: "Відображати попередження, коли починається чат ПП. Можна змінити, відредагувавши рядок перекладу: discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "Коли GPT-бот отримає доступ до ПП, він відповідатиме членам цих груп." - ai_bot_debugging_allowed_groups: "Дозвольте цим групам бачити кнопку налагодження в публікаціях, яка відображає необроблений запит і відповідь ШІ" - ai_bot_public_sharing_allowed_groups: "Дозвольте цим групам ділитися особистими повідомленнями ШІ з громадськістю за унікальним загальнодоступним посиланням. Примітка: якщо ваш сайт вимагає входу, то для доступу до публікацій також потрібно буде ввійти." - ai_bot_add_to_header: "Відображати кнопку в шапці, щоб почати листування зі ШІ" - ai_bot_github_access_token: "Токен доступу до GitHub для використання з інструментами штучного інтелекту GitHub (необхідний для підтримки пошуку)" - ai_stability_api_key: "Ключ API для API Stability.ai" - ai_stability_engine: "Механізм створення зображень для API Stability.ai" - ai_stability_api_url: "URL для API Stability.ai" - ai_google_custom_search_api_key: "Ключ API для Google Custom Search API див. за посиланням: https://developers.google.com/custom-search" - ai_google_custom_search_cx: "CX для API для пошуку Google" - reviewables: - reasons: - flagged_by_toxicity: Плагін ШІ позначив його, класифікувавши як токсичний. - flagged_by_nsfw: Плагін AI позначив це після того, як класифікував принаймні одне з прикріплених зображень як NSFW. - reports: - overall_sentiment: - title: "Загальний настрій" - xaxis: "Позитивні (%)" - yaxis: "Дата" - emotion_anger: - title: "\U0001F620 Гнів" - emotion_disgust: - title: "\U0001F922 Огида" - emotion_fear: - title: "\U0001F628 Страх" - emotion_joy: - title: "\U0001F60A Радість" - emotion_neutral: - title: "\U0001F610 Нейтральна" - emotion_sadness: - title: "\U0001F62D Смуток" - emotion_surprise: - title: "\U0001F632 Сюрприз" - discourse_ai: - unknown_model: "Невідома модель ШІ" - tools: - custom_name: "%{name} (користувацький)" - presets: - browse_web_jina: - name: "Перегляд веб-сторінок (jina.ai)" - exchange_rate: - name: "Обмінний курс" - stock_quote: - name: "Котирування акцій (AlphaVantage)" - image_generation: - name: "Генератор зображень Flux (Together.ai)" - empty_tool: - name: "Почніть з чистого аркуша..." - ai_helper: - errors: - completion_request_failed: "Щось пішло не так під час спроби надати пропозиції. Будь ласка, спробуйте ще раз." - prompts: - translate: Перекласти на %{language} - generate_titles: Запропонувати заголовки тем - proofread: Перевірити текст - markdown_table: Створити таблицю у форматі Markdown - custom_prompt: "Користувацький запит" - explain: "Пояснити" - illustrate_post: "Проілюструвати допис" - painter: - attribution: - stable_diffusion_xl: "Зображення від Stable Diffusion XL" - dall_e_3: "Зображення від DALL-E 3" - image_caption: - attribution: "Підпис від ШІ" - share_ai: - read_more: "Читати повний вміст" - onebox_title: "Розмова ШІ з %{llm_name}" - formatted_excerpt: "Розмова ШІ з %{llm_name}:\n %{excerpt}" - title: "%{title} – бесіда з ШІ – %{site_name}" - errors: - not_allowed: "Вам не дозволено поділитися цією темою" - other_people_in_pm: "Особисті повідомлення з іншими людьми не можна оприлюднювати" - other_content_in_pm: "Особисті повідомлення, що містять дописи інших людей, не можна публікувати публічно" - failed_to_share: "Не вдалося поділитися розмовою" - conversation_deleted: "Спільний доступ до розмови успішно видалено" - ai_bot: - default_pm_prefix: "[ПП від ШІ-бота без назви]" - personas: - default_llm_required: "Перед увімкненням чату потрібно встановити модель LLM за замовчуванням" - cannot_delete_system_persona: "Системні персони не можуть бути видалені, замість цього, будь ласка, вимкніть їх" - cannot_edit_system_persona: "Системні персони можна лише перейменовувати, ви не можете редагувати інструменти або системні підказки, натомість вимкніть і зробіть копію" - github_helper: - name: "Помічник GitHub" - description: "AI Bot, що спеціалізується на допомозі із завданнями та запитаннями, пов’язаними з GitHub" - general: - name: Помічник на форумі - description: "ШІ-бот загального призначення, здатний виконувати різні завдання" - artist: - name: Художник - description: "AI Bot спеціалізується на створенні зображень" - sql_helper: - name: SQL помічник - description: "AI Bot спеціалізується на допомозі створювати запити SQL для цього екземпляра Discourse" - settings_explorer: - name: Провідник налаштувань - description: "AI Bot спеціалізується на допомозі досліджувати налаштування сайту Discourse" - creative: - name: Творчий - description: "AI Bot без зовнішніх інтеграцій, що спеціалізуються на творчих завданнях" - dall_e3: - name: "DALL-E 3" - description: "AI Bot спеціалізується на створенні зображень за допомогою DALL-E 3" - discourse_helper: - name: "Discourse Помічник" - description: "AI Bot спеціалізується на допомозі із завданнями, пов’язаними з Discourse" - topic_not_found: "Всього недоступно, тема не знайдена!" - summarizing: "Підведення підсумків теми" - searching: "Пошук: '%{query}'" - tool_options: - researcher: - max_results: - name: "Максимальна кількість результатів" - google: - base_query: - name: "Базовий пошуковий запит" - description: "Базовий запит для використання під час пошуку. Приклади: 'site:example.com' включатиме лише результати з example.com, before:2022-01-01 включатиме лише результати з 2021 року та раніше. Цей текст додається до пошукового запиту." - read: - read_private: - name: "Читати приват" - description: "Дозволити доступ до всіх тем, до яких користувач має доступ (за замовчуванням включені лише публічні теми)" - search: - search_private: - name: "Пошук Приватний" - description: "Включати в результати пошуку всі теми, до яких користувач має доступ (за умовчанням включено лише загальнодоступні теми)" - max_results: - name: "Максимальна кількість результатів" - description: "Максимальна кількість результатів для включення в пошук – якщо порожні, будуть використані правила за замовчуванням, а кількість буде масштабовано залежно від моделі, що використовується. Найвище значення 100." - base_query: - name: "Базовий пошуковий запит" - description: "Базовий запит для пошуку. Приклад: \"#urgent\" додасть \"#urgent\" до пошукового запиту і знайде лише теми з категорією або тегом \"терміново\"." - tool_summary: - web_browser: "Перегляд веб-сторінок" - github_search_files: "Пошук файлів на GitHub" - github_search_code: "Пошук коду на GitHub" - github_file_content: "Вміст файлу GitHub" - github_pull_request_diff: "Порівняння змін GitHub pull request diff" - random_picker: "Випадковий вибір" - categories: "Список категорій" - search: "Пошук" - tags: "Список тегів" - time: "Час" - summarize: "Підсумок" - image: "Створити зображення" - google: "Шукати в Google" - read: "Читати тему" - setting_context: "Пошук контексту налаштування сайту" - schema: "Пошук схеми бази даних" - search_settings: "Пошук налаштувань сайту" - dall_e: "Створити зображення" - search_meta_discourse: "Пошук на Meta Discourse" - javascript_evaluator: "Оцініть JavaScript" - tool_help: - web_browser: "Переглядайте веб-сторінки за допомогою AI Bot" - github_search_code: "Пошук коду в репозиторії GitHub" - github_search_files: "Пошук файлів у репозиторії GitHub" - github_file_content: "Отримати вміст файлів з репозиторію GitHub" - github_pull_request_diff: "Отримати порівняння змін у запиті на GitHub" - random_picker: "Виберіть випадкове число або випадковий елемент списку" - categories: "Список усіх категорій для публічного перегляду на форумі" - search: "Пошук у всіх публічних темах на форумі" - tags: "Список всіх тегів на форумі" - time: "Знайти час в різних часових поясах" - summary: "Підсумувати тему" - image: "Створити зображення за допомогою Stable Diffusion" - google: "Пошук запиту в Google" - read: "Читати публічну тему на форумі" - setting_context: "Пошук контексту налаштування сайту" - schema: "Пошук схеми бази даних" - search_settings: "Пошук налаштувань сайту" - dall_e: "Створити зображення за допомогою DALL-E 3" - search_meta_discourse: "Пошук на Meta Discourse" - javascript_evaluator: "Оцініть JavaScript" - tool_description: - web_browser: "Читання %{url}" - github_search_files: "Шукав «%{keywords}» в %{repo}/%{branch}" - github_search_code: "Шукав '%{query}' у %{repo}" - github_pull_request_diff: "%{repo} %{pull_id}%{repo}" - github_file_content: "Отриманий вміст %{file_paths} з %{repo_name}@%{branch}" - random_picker: "Вибір із %{options}, вибрано: %{result}" - read: "Читання: %{title}" - time: "Час у %{timezone} %{time}" - summarize: "Підсумок %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - one: "Знайдено %{count} категорію" - few: "Знайдено %{count} категорії" - many: "Знайдено %{count} категорій" - other: "Знайдено %{count} категорій" - tags: - one: "Знайдено %{count} тег" - few: "Знайдено %{count} теги" - many: "Знайдено %{count} тегів" - other: "Знайдено %{count} тегів" - search: - one: "Знайдено %{count} результат для '%{query}'" - few: "Знайдено %{count} результати для '%{query}'" - many: "Знайдено %{count} результатів для '%{query}'" - other: "Знайдено %{count} результатів для \"%{query}\"" - search_meta_discourse: - one: "Знайдено %{count} результат для '%{query}'" - few: "Знайдено %{count} результати для '%{query}'" - many: "Знайдено %{count} результатів для '%{query}'" - other: "Знайдено %{count} результатів для \"%{query}\"" - google: - one: "Знайдено %{count} результат для '%{query}'" - few: "Знайдено %{count} результати для '%{query}'" - many: "Знайдено %{count} результатів для '%{query}'" - other: "Знайдено %{count} результатів для \"%{query}\"" - setting_context: "Читання контексту для: %{setting_name}" - schema: "%{tables}" - search_settings: - one: "Знайдено %{count} результат для '%{query}'" - few: "Знайдено %{count} результати для '%{query}'" - many: "Знайдено %{count} результатів для '%{query}'" - other: "Знайдено %{count} результатів для '%{query}'" - summarization: - configuration_hint: - one: "Спочатку налаштуйте параметр `%{setting}`." - few: "Спочатку налаштуйте ці параметри: `%{settings}`." - many: "Спочатку налаштуйте ці параметри: `%{settings}`." - other: "Спочатку налаштуйте ці параметри: `%{settings}`." - chat: - no_targets: "За вибраний період повідомлень не було." - sentiment: - reports: - post_emotion: - sadness: "Смуток \U0001F622" - surprise: "Сюрприз \U0001F631" - neutral: "Нейтральна \U0001F610" - fear: "Страх \U0001F628" - anger: "Гнів \U0001F621" - joy: "Радість \U0001F600" - disgust: "Огида \U0001F922" - sentiment_analysis: - positive: "Позитивний" - negative: "Негативний" - neutral: "Нейтральна" - llm: - configuration: - disable_module_first: "Спочатку потрібно вимкнути %{setting} ." - model_unreachable: "Нам не вдалося отримати відповідь від цієї моделі. Спочатку перевірте свої налаштування." - invalid_seeded_model: "Ви не можете використовувати цю модель із цією функцією" - endpoints: - not_configured: "%{display_name} (не налаштовано)" - configuration_hint: - one: "Переконайтеся, що параметр `%{settings}` налаштовано." - few: "Переконайтеся, що параметри `%{settings}` налаштовано." - many: "Переконайтеся, що налаштовано параметри: `%{settings}`." - other: "Переконайтеся, що налаштовано параметри: `%{settings}`." - delete_failed: - one: "Ми не змогли видалити цю модель, оскільки її використовує %{settings} . Оновіть налаштування та повторіть спробу." - few: "Ми не змогли видалити цю модель, оскільки її використовують %{settings} . Оновіть налаштування та повторіть спробу." - many: "Ми не змогли видалити цю модель, оскільки її використовують %{settings} . Оновіть налаштування та повторіть спробу." - other: "Ми не змогли видалити цю модель, оскільки її використовують %{settings} . Оновіть налаштування та повторіть спробу." - cannot_edit_builtin: "Ви не можете редагувати вбудовану модель." - embeddings: - cannot_edit_builtin: "Ви не можете редагувати вбудовану модель." - configuration: - disable_embeddings: "Спершу потрібно вимкнути функцію «AI embeddings enabled»." - llm_models: - missing_provider_param: "%{param} не може бути пустим" - ai_staff_action_logger: - updated: "оновлено" - removed: "вилучені" - errors: - no_query_specified: Параметр запиту є обов'язковим, будь ласка, вкажіть його. - no_user_for_persona: Зазначена персона не має пов'язаного з нею користувача. - persona_not_found: Вказана персона не існує. Перевірте параметри persona_name або persona_id. - no_user_specified: Ім'я користувача або параметр user_unique_id є обов'язковим, будь ласка, вкажіть його. - user_not_found: Зазначений користувач не існує. Перевірте параметр імені користувача. - persona_disabled: Вказана особа вимкнена. Перевірте параметри persona_name або persona_id. - no_default_llm: Особа повинна мати значення default_llm. - user_not_allowed: Користувачеві заборонено брати участь у темі. - prompt_message_length: Повідомлення %{idx} перевищує ліміт у 1000 символів. diff --git a/config/locales/server.ur.yml b/config/locales/server.ur.yml deleted file mode 100644 index 607292cd..00000000 --- a/config/locales/server.ur.yml +++ /dev/null @@ -1,27 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -ur: - reports: - overall_sentiment: - yaxis: "تاریخ" - emotion_neutral: - title: "\U0001F610 نیوٹرل" - discourse_ai: - ai_bot: - tool_summary: - search: "تلاش کریں" - time: "وقت" - summarize: "خلاصہ" - sentiment: - reports: - post_emotion: - neutral: "نیوٹرل \U0001F610" - sentiment_analysis: - neutral: "نیوٹرل" - ai_staff_action_logger: - updated: "اپ ڈیٹ" - removed: "ہٹا دیا" diff --git a/config/locales/server.vi.yml b/config/locales/server.vi.yml deleted file mode 100644 index 1929e086..00000000 --- a/config/locales/server.vi.yml +++ /dev/null @@ -1,19 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -vi: - reports: - overall_sentiment: - yaxis: "Ngày" - discourse_ai: - ai_bot: - tool_summary: - search: "Tìm kiếm" - time: "Thời gian" - summarize: "Tóm tắt" - ai_staff_action_logger: - updated: "đã cập nhật" - removed: "đã xóa" diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml deleted file mode 100644 index 6222a0f3..00000000 --- a/config/locales/server.zh_CN.yml +++ /dev/null @@ -1,434 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -zh_CN: - discourse_automation: - ai: - flag_types: - review: "将帖子添加到审核队列" - spam: "标记为垃圾内容并隐藏帖子" - spam_silence: "标记为垃圾内容,隐藏帖子并将用户禁言" - scriptables: - llm_triage: - title: 使用 AI 对帖子进行分类 - description: "使用大语言模型对帖子进行分类" - flagged_post: | -
模型的回答:
-

%%LLM_RESPONSE%%

- %%AUTOMATION_NAME%% 规则触发。 - llm_report: - title: 使用 AI 的定期报告 - description: "基于大语言模型的定期报告" - site_settings: - discourse_ai_enabled: "启用 Discourse AI 插件。" - ai_toxicity_enabled: "启用毒性模块。" - ai_toxicity_inference_service_api_endpoint: "为毒性模块运行 API 的 URL" - ai_toxicity_inference_service_api_key: "毒性 API 的 API 密钥" - ai_toxicity_inference_service_api_model: "用于推断的模型。多语言模型适用于意大利语、法语、俄语、葡萄牙语、西班牙语和土耳其语。" - ai_toxicity_flag_automatically: "自动举报高于已配置阈值的帖子/聊天消息。" - ai_toxicity_flag_threshold_toxicity: "毒性:粗鲁、无礼或不合理的评论,可能会让您离开讨论或放弃分享您的观点" - ai_toxicity_flag_threshold_severe_toxicity: "严重毒性:极度仇恨、激进或无礼的评论,很可能让您离开讨论或放弃分享您的观点" - ai_toxicity_flag_threshold_obscene: "淫秽" - ai_toxicity_flag_threshold_identity_attack: "身份攻击" - ai_toxicity_flag_threshold_insult: "侮辱" - ai_toxicity_flag_threshold_threat: "威胁" - ai_toxicity_flag_threshold_sexual_explicit: "露骨色情" - ai_toxicity_groups_bypass: "这些群组中的用户的帖子不会被毒性模块分类。" - ai_sentiment_enabled: "启用情绪模块。" - ai_sentiment_inference_service_api_endpoint: "为情绪模块运行 API 的 URL" - ai_sentiment_inference_service_api_key: "情绪 API 的 API 密钥" - ai_sentiment_models: "用于推断的模型。情绪将帖子分类为正面/中性/负面空间。情绪按愤怒/厌恶/恐惧/欢乐/中性/悲伤/惊讶空间进行分类。" - ai_nsfw_detection_enabled: "启用 NSFW 模块。" - ai_nsfw_inference_service_api_endpoint: "为 NSFW 模块运行 API 的 URL" - ai_nsfw_inference_service_api_key: "NSFW API 的 API 密钥" - ai_nsfw_flag_automatically: "自动举报高于已配置阈值的 NSFW 帖子。" - ai_nsfw_flag_threshold_general: "图片被视为 NSFW 的一般阈值。" - ai_nsfw_flag_threshold_drawings: "绘图被视为 NSFW 的阈值。" - ai_nsfw_flag_threshold_hentai: "分类为成人内容的图片被视为 NSFW 的阈值。" - ai_nsfw_flag_threshold_porn: "分类为色情作品的图片被视为 NSFW 的阈值。" - ai_nsfw_flag_threshold_sexy: "分类为性感的图片被视为 NSFW 的阈值。" - ai_nsfw_models: "用于 NSFW 推断的模型。" - ai_helper_enabled: "启用 AI 助手。" - composer_ai_helper_allowed_groups: "这些群组中的用户将在编辑器中看到 AI 助手按钮。" - ai_helper_allowed_in_pm: "在私信中启用编辑器的 AI 助手。" - ai_helper_model: "用于 AI 助手的模型。" - ai_helper_custom_prompts_allowed_groups: "这些群组中的用户将在 AI 助手中看到自定义提示选项。" - ai_helper_automatic_chat_thread_title_delay: "在 AI 助手自动设置聊天对话标题之前的延迟(以分钟为单位)。" - ai_helper_automatic_chat_thread_title: "根据话题内容自动设置聊天对话标题。" - ai_helper_illustrate_post_model: "用于编辑器 AI 助手的为帖子创建插图功能的模型" - ai_helper_enabled_features: "选择要在 AI 助手中启用的功能。" - post_ai_helper_allowed_groups: "能够在帖子中访问 AI 助手功能的用户群组" - ai_helper_image_caption_model: "选择用于生成图片标题的模型" - ai_auto_image_caption_allowed_groups: "这些群组中的用户可以切换自动生成图片标题。" - ai_embeddings_selected_model: "使用所选模型生成嵌入向量。" - ai_embeddings_generate_for_pms: "为个人消息生成嵌入向量。" - ai_embeddings_semantic_related_topics_enabled: "将语义搜索用于相关话题。" - ai_embeddings_semantic_related_topics: "相关话题部分中显示的最大话题数。" - ai_embeddings_backfill_batch_size: "每 15 分钟回填的嵌入向量数。" - ai_embeddings_semantic_search_enabled: "启用全页语义搜索。" - ai_embeddings_semantic_quick_search_enabled: "在搜索菜单弹出窗口中启用语义搜索选项。" - ai_embeddings_semantic_related_include_closed_topics: "在语义搜索结果中包含已关闭话题" - ai_embeddings_semantic_search_hyde_model: "用于在语义搜索中扩展关键字以获得更好结果的模型" - ai_embeddings_per_post_enabled: 为每个帖子生成嵌入向量 - ai_custom_summarization_allowed_groups: "能够创建新总结的群组。" - ai_pm_summarization_allowed_groups: "可以创建摘要并在私信中查看的群组。" - ai_summary_gists_allowed_groups: "可以查看热门话题列表中的要点的群组。" - ai_summary_backfill_maximum_topics_per_hour: "每小时回填的话题摘要数。" - ai_bot_enabled: "启用 AI 机器人模块。" - ai_bot_enable_chat_warning: "启动私信聊天时显示警告。可以通过编辑翻译字符串进行覆盖:discourse_ai.ai_bot.pm_warning" - ai_bot_allowed_groups: "当 GPT 机器人可以访问私信时,它将回复这些群组的成员。" - ai_bot_debugging_allowed_groups: "允许这些群组在帖子上看到显示原始 AI 请求和回答的调试按钮" - ai_bot_public_sharing_allowed_groups: "允许这些群组通过唯一的公开链接与公众分享 AI 个人消息。注意:如果您的网站需要登录,分享时也需要登录。" - ai_bot_add_to_header: "在标题中显示按钮以使用 AI 机器人启动私信" - ai_bot_github_access_token: "使用 GitHub AI 工具所需的 GitHub 访问令牌(搜索支持需要)" - ai_stability_api_key: "stability.ai API 的 API 密钥" - ai_stability_engine: "用于 stability.ai API 的图片生成引擎" - ai_stability_api_url: "stability.ai API 的 URL" - ai_google_custom_search_api_key: "Google 自定义搜索 API 的 API 密钥,请参阅:https://developers.google.com/custom-search" - ai_google_custom_search_cx: "Google 自定义搜索 API 的 CX" - reviewables: - reasons: - flagged_by_toxicity: AI 插件将其分类为有毒后对其进行了举报。 - flagged_by_nsfw: AI 插件在将至少一张附加图片分类为 NSFW 后对其进行了举报。 - reports: - overall_sentiment: - title: "整体情绪" - description: '该图表会比较分类为积极或消极的帖子数。当积极分数或消极分数大于设定的阈值分数时,会计算这些值。这意味着不显示中性的帖子。私信 (PM) 也不包括在内。使用“cardiffnlp/twitter-roberta-base-sentiment-latest”进行分类' - xaxis: "积极 (%)" - yaxis: "日期" - emotion_admiration: - title: "\U0001F929 钦佩" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为钦佩情绪的帖子。" - emotion_amusement: - title: "\U0001F604 愉悦" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为愉悦情绪的帖子。" - emotion_anger: - title: "\U0001F620 愤怒" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为愤怒情绪的帖子。" - emotion_annoyance: - title: "\U0001F612 烦恼" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为烦恼情绪的帖子。" - emotion_approval: - title: "\U0001F44D 赞同" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为赞同情绪的帖子。" - emotion_caring: - title: "\U0001F917 关怀" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为关怀情绪的帖子。" - emotion_confusion: - title: "\U0001F615 困惑" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为困惑情绪的帖子。" - emotion_curiosity: - title: "\U0001F914 好奇" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为好奇情绪的帖子。" - emotion_desire: - title: "\U0001F60D 渴望" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为渴望情绪的帖子。" - emotion_disappointment: - title: "\U0001F61E 沮丧" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为沮丧情绪的帖子。" - emotion_disapproval: - title: "\U0001F44E 不赞同" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为不赞同情绪的帖子。" - emotion_disgust: - title: "\U0001F922 厌恶" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为厌恶情绪的帖子。" - emotion_embarrassment: - title: "\U0001F633 尴尬" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为尴尬情绪的帖子。" - emotion_excitement: - title: "\U0001F92A 兴奋" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为兴奋情绪的帖子。" - emotion_fear: - title: "\U0001F628 恐惧" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为恐惧情绪的帖子。" - emotion_gratitude: - title: "\U0001F64F 感谢" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为感谢情绪的帖子。" - emotion_grief: - title: "\U0001F622 悲伤" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为悲伤情绪的帖子。" - emotion_joy: - title: "\U0001F60A 开心" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为开心情绪的帖子。" - emotion_love: - title: '❤️ 爱心' - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为爱心情绪的帖子。" - emotion_nervousness: - title: "\U0001F630 紧张" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为紧张情绪的帖子。" - emotion_neutral: - title: "\U0001F610 中性" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为中性情绪的帖子。" - emotion_optimism: - title: "\U0001F31F 乐观" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为乐观情绪的帖子。" - emotion_pride: - title: "\U0001F981 骄傲" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为骄傲情绪的帖子。" - emotion_realization: - title: "\U0001F4A1 领悟" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为领悟情绪的帖子。" - emotion_relief: - title: "\U0001F60C 缓解" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为缓解情绪的帖子。" - emotion_remorse: - title: "\U0001F614 懊悔" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为懊悔情绪的帖子。" - emotion_sadness: - title: "\U0001F62D 悲伤" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为悲伤情绪的帖子。" - emotion_surprise: - title: "\U0001F632 惊讶" - description: "使用“SamLowe/roberta-base-go_emotions”模型,通过 AI 分类为惊讶情绪的帖子。" - discourse_ai: - ai_artifact: - view_source: "查看来源" - view_changes: "查看更改" - unknown_model: "未知 AI 模型" - tools: - custom_name: "%{name}(自定义)" - presets: - browse_web_jina: - name: "浏览网页 (jina.ai)" - exchange_rate: - name: "汇率" - stock_quote: - name: "股票报价 (AlphaVantage)" - image_generation: - name: "Flux 图片生成器 (Together.ai)" - empty_tool: - name: "从空白开始…" - ai_helper: - errors: - completion_request_failed: "尝试提供建议时出错。请重试。" - prompts: - translate: 翻译为%{language} - generate_titles: 建议话题标题 - proofread: 审校文本 - markdown_table: 生成 Markdown 表 - custom_prompt: "自定义提示" - explain: "解释" - illustrate_post: "为帖子创建插图" - replace_dates: "智能日期" - painter: - attribution: - stable_diffusion_xl: "图片由 Stable Diffusion XL 提供" - dall_e_3: "图片由 DALL-E 3 提供" - image_caption: - attribution: "由 AI 生成标题" - share_ai: - read_more: "阅读完整转写文稿" - onebox_title: "与 %{llm_name} 的 AI 对话" - formatted_excerpt: "与 %{llm_name} 的 AI 对话:\n %{excerpt}" - title: "%{title} - AI 对话 - %{site_name}" - errors: - not_allowed: "您无权分享此话题" - other_people_in_pm: "不能公开分享与其他人的个人消息" - other_content_in_pm: "不能公开分享包含其他人发表的帖子的个人消息" - failed_to_share: "无法分享对话" - conversation_deleted: "对话分享已成功删除" - spam_detection: - flag_reason: "被 Discourse AI 标记为垃圾内容" - silence_reason: "用户被 Discourse AI 自动禁言" - invalid_error_type: "提供的错误类型无效" - unexpected: "发生意外错误" - bot_user_update_failed: "无法更新垃圾内容扫描机器人用户" - ai_bot: - reply_error: "抱歉,我们的系统在尝试回复时似乎遇到了意外问题。\n\n[details='Error details']\n%{details}\n[/details]" - default_pm_prefix: "[无标题 AI 机器人私信]" - personas: - default_llm_required: "启用聊天之前需要默认的 LLM 模型" - cannot_delete_system_persona: "无法删除系统角色,请改为将其禁用" - cannot_edit_system_persona: "系统角色只能重命名,您不能编辑工具或系统提示,但可以禁用和复制" - github_helper: - name: "GitHub 助手" - description: "专门协助处理 GitHub 相关任务和问题的 AI 机器人" - general: - name: 论坛助手 - description: "能够执行各种任务的通用 AI 机器人" - artist: - name: 艺术家 - description: "专门用于生成图片的 AI 机器人" - sql_helper: - name: SQL 助手 - description: "专门帮助在此 Discourse 实例上编写 SQL 查询的 AI 机器人" - settings_explorer: - name: 设置资源管理器 - description: "专门帮助探索 Discourse 站点设置的 AI 机器人" - creative: - name: 创作者 - description: "无需外部集成的专门从事创作任务的 AI 机器人" - dall_e3: - name: "DALL-E 3" - description: "专门使用 DALL-E 3 生成图片的 AI 机器人" - discourse_helper: - name: "Discourse 助手" - description: "专门帮助处理 Discourse 相关任务的 AI 机器人" - web_artifact_creator: - name: "Web 工件创建者" - description: "专注于创建交互式 Web 工件的 AI 机器人" - custom_prompt: - name: "自定义提示" - smart_dates: - name: "智能日期" - topic_not_found: "总结不可用,找不到话题!" - summarizing: "正在总结话题" - searching: "搜索:'%{query}'" - tool_options: - researcher: - max_results: - name: "最大结果数" - google: - base_query: - name: "基本搜索查询" - description: "搜索时使用的基本查询。例如:'site:example.com' 将仅包括 example.com 中的结果,before:2022-01-01 将仅包括 2021 年及之前的结果。此文本会附加到搜索查询的前面。" - read: - read_private: - name: "阅读不公开话题" - description: "允许访问用户可以访问的所有话题(默认情况下仅包含公开话题)" - search: - search_private: - name: "搜索不公开话题" - description: "在搜索结果中包含用户可以访问的所有话题(默认情况下仅包含公开话题)" - max_results: - name: "最大结果数" - description: "要包含在搜索中的最大结果数 – 如果留空,将使用默认规则,并且计数将根据使用的模型进行调整。最大值为 100。" - base_query: - name: "基本搜索查询" - description: "搜索时要使用的基本查询。示例:'#urgent' 会在搜索查询前面加上 '#urgent',并且仅包含具有紧急类别或标签的话题。" - tool_summary: - update_artifact: "更新 Web 工件" - create_artifact: "创建 Web 工件" - web_browser: "浏览网页" - github_search_files: "GitHub 搜索文件" - github_search_code: "GitHub 代码搜索" - github_file_content: "GitHub 文件内容" - github_pull_request_diff: "GitHub 拉取请求差异" - random_picker: "随机选择器" - categories: "列出类别" - search: "搜索" - tags: "列出标签" - time: "时间" - summarize: "总结" - image: "生成图片" - google: "搜索 Google" - read: "阅读话题" - setting_context: "查找网站设置上下文" - schema: "查找数据库架构" - search_settings: "正在搜索网站设置" - dall_e: "生成图片" - search_meta_discourse: "搜索元 Discourse" - javascript_evaluator: "评估 JavaScript" - tool_help: - update_artifact: "使用 AI 机器人更新 Web 工件" - create_artifact: "使用 AI 机器人创建 Web 工件" - web_browser: "使用 AI 机器人浏览网页" - github_search_code: "在 GitHub 仓库中搜索代码" - github_search_files: "在 GitHub 仓库中搜索文件" - github_file_content: "从 GitHub 仓库检索文件内容" - github_pull_request_diff: "检索 GitHub 拉取请求差异" - random_picker: "选择一个随机数或列表的随机元素" - categories: "列出论坛上所有公开可见的类别" - search: "搜索论坛上的所有公开话题" - tags: "列出论坛上的所有标签" - time: "查找不同时区的时间" - summary: "总结话题" - image: "使用 Stable Diffusion 生成图片" - google: "在 Google 中搜索查询" - read: "在论坛上阅读公开话题" - setting_context: "查找网站设置上下文" - schema: "查找数据库架构" - search_settings: "搜索网站设置" - dall_e: "使用 DALL-E 3 生成图片" - search_meta_discourse: "搜索元 Discourse" - javascript_evaluator: "评估 JavaScript" - tool_description: - update_artifact: "已使用 AI 机器人更新 Web 工件" - web_browser: "正在阅读 %{url}" - github_search_files: "已在 %{repo}/%{branch} 中搜索 '%{keywords}'" - github_search_code: "已在 %{repo} 中搜索 '%{query}'" - github_pull_request_diff: "%{repo} %{pull_id}" - github_file_content: "已从 %{repo_name}@%{branch} 检索到 %{file_paths} 的内容" - random_picker: "正在从 %{options} 中选择,已选:%{result}" - read: "正在阅读:%{title}" - time: "%{timezone} 的时间为 %{time}" - summarize: "已总结 %{title}" - dall_e: "%{prompt}" - create_image: "%{prompt}" - edit_image: "%{prompt}" - image: "%{prompt}" - categories: - other: "找到 %{count} 个类别" - tags: - other: "找到 %{count} 个标签" - search: - other: "找到 '%{query}' 的 %{count} 个结果" - search_meta_discourse: - other: "找到 '%{query}' 的 %{count} 个结果" - google: - other: "找到 '%{query}' 的 %{count} 个结果" - setting_context: "正在阅读上下文:%{setting_name}" - schema: "%{tables}" - search_settings: - other: "找到“%{query}”的 %{count} 个结果" - summarization: - configuration_hint: - other: "首先配置这些设置:%{settings}" - chat: - no_targets: "在所选时间段内没有消息。" - sentiment: - reports: - overall_sentiment: "整体情绪(积极 - 消极)" - post_emotion: - sadness: "悲伤 \U0001F622" - surprise: "惊讶 \U0001F631" - neutral: "中性 \U0001F610" - fear: "恐惧 \U0001F628" - anger: "愤怒 \U0001F621" - joy: "开心\U0001F600" - disgust: "厌恶 \U0001F922" - sentiment_analysis: - positive: "积极" - negative: "消极" - neutral: "中性" - llm: - configuration: - disable_module_first: "您必须先禁用“%{setting}”。" - set_llm_first: "首先设置%{setting}" - model_unreachable: "我们无法从此模型获得回应。请先检查您的设置。" - invalid_seeded_model: "您无法在此模型中使用此功能" - must_select_model: "必须先选择一个 LLM" - endpoints: - not_configured: "%{display_name}(未配置)" - configuration_hint: - other: "确保配置了以下设置:%{settings}" - delete_failed: - other: "由于 %{settings} 正在使用此模型,无法将其删除。请更新设置并重试。" - cannot_edit_builtin: "您无法编辑内置模型。" - embeddings: - delete_failed: "此模型目前正在使用中。请先更新 `ai embeddings selected model`。" - cannot_edit_builtin: "您无法编辑内置模型。" - configuration: - disable_embeddings: "您必须先禁用 'ai embeddings enabled'。" - choose_model: "首先设置 'ai embeddings selected model'。" - llm_models: - missing_provider_param: "%{param} 不能为空" - bedrock_invalid_url: "请填写所有字段以使用此模型。" - ai_staff_action_logger: - updated: "更新" - removed: "已移除" - errors: - quota_exceeded: "您已超出此模型的配额。请在 %{relative_time}后再试。" - quota_required: "必须为此模型指定最大词元数或使用次数" - no_query_specified: 查询参数为必填项,请指定。 - no_user_for_persona: 指定的角色没有与之关联的用户。 - persona_not_found: 指定的角色不存在。请检查 persona_name 或 persona_id 参数。 - no_user_specified: username 或 user_unique_id 参数为必填项,请指定。 - user_not_found: 指定的用户不存在。请检查 username 参数。 - persona_disabled: 指定的角色被禁用。请检查 persona_name 或 persona_id 参数。 - no_default_llm: 角色必须定义一个 default_llm。 - user_not_allowed: 该用户无法参与该话题。 - prompt_message_length: 消息 %{idx} 超过 1000 个字符限制。 diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml deleted file mode 100644 index 6e592f3a..00000000 --- a/config/locales/server.zh_TW.yml +++ /dev/null @@ -1,27 +0,0 @@ -# WARNING: Never edit this file. -# It will be overwritten when translations are pulled from Crowdin. -# -# To work with us on translations, join this project: -# https://translate.discourse.org/ - -zh_TW: - reports: - overall_sentiment: - yaxis: "日期" - emotion_neutral: - title: "\U0001F610 中性" - discourse_ai: - ai_bot: - tool_summary: - search: "搜尋" - time: "時間" - summarize: "總結" - sentiment: - reports: - post_emotion: - neutral: "中性 \U0001F610" - sentiment_analysis: - neutral: "中性" - ai_staff_action_logger: - updated: "更新時間" - removed: "已移除" diff --git a/config/routes.rb b/config/routes.rb deleted file mode 100644 index d3ea4497..00000000 --- a/config/routes.rb +++ /dev/null @@ -1,141 +0,0 @@ -# frozen_string_literal: true - -DiscourseAi::Engine.routes.draw do - scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do - post "suggest" => "assistant#suggest" - post "suggest_title" => "assistant#suggest_title" - post "suggest_category" => "assistant#suggest_category" - post "suggest_tags" => "assistant#suggest_tags" - post "stream_suggestion" => "assistant#stream_suggestion" - post "caption_image" => "assistant#caption_image" - end - - scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do - get "semantic-search" => "embeddings#search" - get "quick-search" => "embeddings#quick_search" - end - - scope module: :discord, path: "/discord", defaults: { format: :json } do - post "interactions" => "bot#interactions" - end - - scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do - get "bot-username" => "bot#show_bot_username" - get "post/:post_id/show-debug-info" => "bot#show_debug_info" - get "show-debug-info/:id" => "bot#show_debug_info_by_id" - post "post/:post_id/stop-streaming" => "bot#stop_streaming_response" - - get "discover" => "bot#discover" - post "discover/continue-convo" => "bot#discover_continue_convo" - end - - scope module: :ai_bot, path: "/ai-bot/shared-ai-conversations" do - post "/" => "shared_ai_conversations#create" - delete "/:share_key" => "shared_ai_conversations#destroy" - get "/:share_key" => "shared_ai_conversations#show" - get "/asset/:version/:name" => "shared_ai_conversations#asset" - get "/preview/:topic_id" => "shared_ai_conversations#preview" - end - - scope module: :ai_bot, path: "/ai-bot/conversations" do - get "/" => "conversations#index" - end - - scope module: :ai_bot, path: "/ai-bot/artifacts" do - get "/:id" => "artifacts#show" - get "/:id/:version" => "artifacts#show" - end - - scope module: :ai_bot, path: "/ai-bot/artifact-key-values/:artifact_id" do - get "/" => "artifact_key_values#index" - post "/" => "artifact_key_values#set" - delete "/:key" => "artifact_key_values#destroy" - delete "/" => "artifact_key_values#destroy" - end - - scope module: :summarization, path: "/summarization", defaults: { format: :json } do - get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ } - get "/channels/:channel_id" => "chat_summary#show" - end - - scope module: :sentiment, path: "/sentiment", defaults: { format: :json } do - get "/posts" => "sentiment#posts", :constraints => StaffConstraint.new - end -end - -Discourse::Application.routes.draw do - mount ::DiscourseAi::Engine, at: "discourse-ai" - - get "admin/dashboard/sentiment" => "discourse_ai/admin/dashboard#sentiment", - :constraints => StaffConstraint.new - - scope "/admin/plugins/discourse-ai", constraints: AdminConstraint.new do - resources :ai_personas, - only: %i[index new create edit update destroy], - path: "ai-personas", - controller: "discourse_ai/admin/ai_personas" - - post "/ai-personas/stream-reply" => "discourse_ai/admin/ai_personas#stream_reply" - - resources( - :ai_tools, - only: %i[index new create edit update destroy], - path: "ai-tools", - controller: "discourse_ai/admin/ai_tools", - ) - - post "/ai-tools/:id/test", to: "discourse_ai/admin/ai_tools#test" - get "/ai-tools/:id/export", to: "discourse_ai/admin/ai_tools#export", format: :json - post "/ai-tools/import", to: "discourse_ai/admin/ai_tools#import" - - post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user" - get "/ai-personas/:id/export", to: "discourse_ai/admin/ai_personas#export", format: :json - post "/ai-personas/import", to: "discourse_ai/admin/ai_personas#import" - - put "/ai-personas/:id/files/remove", to: "discourse_ai/admin/ai_personas#remove_file" - get "/ai-personas/:id/files/status", to: "discourse_ai/admin/ai_personas#indexing_status_check" - - post "/rag-document-fragments/files/upload", - to: "discourse_ai/admin/rag_document_fragments#upload_file" - get "/rag-document-fragments/files/status", - to: "discourse_ai/admin/rag_document_fragments#indexing_status_check" - - get "/ai-usage", to: "discourse_ai/admin/ai_usage#show" - get "/ai-usage-report", to: "discourse_ai/admin/ai_usage#report" - get "/ai-spam", to: "discourse_ai/admin/ai_spam#show" - put "/ai-spam", to: "discourse_ai/admin/ai_spam#update" - post "/ai-spam/test", to: "discourse_ai/admin/ai_spam#test" - post "/ai-spam/fix-errors", to: "discourse_ai/admin/ai_spam#fix_errors" - - resources :ai_llms, - only: %i[index new create edit update destroy], - path: "ai-llms", - controller: "discourse_ai/admin/ai_llms" do - collection { get :test } - end - - resources :ai_llm_quotas, - controller: "discourse_ai/admin/ai_llm_quotas", - path: "quotas", - only: %i[index create update destroy] - - resources :ai_embeddings, - only: %i[index new create edit update destroy], - path: "ai-embeddings", - controller: "discourse_ai/admin/ai_embeddings" do - collection { get :test } - end - - resources :ai_features, - only: %i[index edit], - path: "ai-features", - controller: "discourse_ai/admin/ai_features" - end -end - -Discourse::Application.routes.append do - get "u/:username/preferences/ai" => "users#preferences", - :constraints => { - username: RouteFormat.username, - } -end diff --git a/config/settings.yml b/config/settings.yml deleted file mode 100644 index 43646ff6..00000000 --- a/config/settings.yml +++ /dev/null @@ -1,596 +0,0 @@ -discourse_ai: - discourse_ai_enabled: - default: false - client: true - ai_artifact_security: - client: true - type: enum - default: "strict" - choices: - - "disabled" - - "lax" - - "hybrid" - - "strict" - - ai_sentiment_enabled: - default: false - client: true - ai_sentiment_model_configs: - default: "" - json_schema: DiscourseAi::Sentiment::SentimentSiteSettingJsonSchema - ai_sentiment_backfill_maximum_posts_per_hour: - default: 2500 - min: 0 - max: 10000 - hidden: true - ai_sentiment_backfill_post_max_age_days: - default: 60 - hidden: true - - ai_openai_image_generation_url: "https://api.openai.com/v1/images/generations" - ai_openai_image_edit_url: "https://api.openai.com/v1/images/edits" - ai_openai_embeddings_url: - hidden: true - default: "https://api.openai.com/v1/embeddings" - ai_openai_organization: - default: "" - hidden: true - ai_openai_api_key: - default: "" - secret: true - ai_stability_api_key: - default: "" - secret: true - ai_stability_api_url: - default: "https://api.stability.ai" - ai_stability_engine: - default: "stable-diffusion-xl-1024-v1-0" - type: enum - choices: - - "sd3" - - "sd3-turbo" - - "stable-diffusion-xl-1024-v1-0" - - "stable-diffusion-768-v2-1" - - "stable-diffusion-v1-5" - ai_hugging_face_tei_endpoint: - hidden: true - default: "" - ai_hugging_face_tei_endpoint_srv: - default: "" - hidden: true - ai_hugging_face_tei_api_key: - default: "" - hidden: true - ai_hugging_face_tei_reranker_endpoint: - default: "" - ai_hugging_face_tei_reranker_endpoint_srv: - default: "" - hidden: true - ai_hugging_face_tei_reranker_api_key: "" - ai_google_custom_search_api_key: - default: "" - secret: true - ai_google_custom_search_cx: - default: "" - ai_cloudflare_workers_account_id: - default: "" - secret: true - hidden: true - ai_cloudflare_workers_api_token: - default: "" - secret: true - hidden: true - ai_gemini_api_key: - default: "" - hidden: true - ai_strict_token_counting: - default: false - hidden: true - - ai_helper_enabled: - default: false - client: true - validator: "DiscourseAi::Configuration::LlmDependencyValidator" - area: "ai-features/ai_helper" - composer_ai_helper_allowed_groups: - type: group_list - list_type: compact - default: "3|14" # 3: @staff, 14: @trust_level_4 - allow_any: false - refresh: true - area: "ai-features/ai_helper" - ai_helper_allowed_in_pm: - default: false - client: true - area: "ai-features/ai_helper" - ai_helper_model: - default: "" - allow_any: false - type: enum - enum: "DiscourseAi::Configuration::LlmEnumerator" - hidden: true - ai_helper_custom_prompts_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01 - type: group_list - list_type: compact - default: "3" # 3: @staff - allow_any: false - refresh: true - hidden: true - post_ai_helper_allowed_groups: - type: group_list - list_type: compact - default: "3|14" # 3: @staff, 14: @trust_level_4 - allow_any: false - refresh: true - area: "ai-features/ai_helper" - ai_helper_automatic_chat_thread_title: - default: false - area: "ai-features/ai_helper" - ai_helper_automatic_chat_thread_title_delay: - default: 5 - area: "ai-features/ai_helper" - ai_helper_illustrate_post_model: - default: disabled - type: enum - choices: - - stable_diffusion_xl - - dall_e_3 - - disabled - area: "ai-features/ai_helper" - ai_helper_enabled_features: - client: true - default: "suggestions|context_menu" - type: list - list_type: compact - allow_any: false - refresh: true - choices: - - "suggestions" - - "context_menu" - - "image_caption" - area: "ai-features/ai_helper" - ai_helper_image_caption_model: - default: "" - type: enum - enum: "DiscourseAi::Configuration::LlmVisionEnumerator" - hidden: true - ai_auto_image_caption_allowed_groups: - client: true - type: group_list - list_type: compact - default: "10" # 10: @trust_level_0 - allow_any: false - refresh: true - area: "ai-features/ai_helper" - ai_helper_model_allowed_seeded_models: - default: "" - hidden: true - type: list - list_type: compact - ai_helper_image_caption_model_allowed_seeded_models: - default: "" - hidden: true - type: list - list_type: compact - ai_helper_proofreader_persona: - default: "-22" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - ai_helper_title_suggestions_persona: - default: "-23" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - ai_helper_explain_persona: - default: "-24" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - ai_helper_post_illustrator_persona: - default: "-21" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - ai_helper_smart_dates_persona: - default: "-19" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - ai_helper_translator_persona: - default: "-25" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - ai_helper_markdown_tables_persona: - default: "-20" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - ai_helper_custom_prompt_persona: - default: "-18" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - ai_helper_image_caption_persona: - default: "-26" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/ai_helper" - - ai_embeddings_enabled: - default: false - client: true - validator: "DiscourseAi::Configuration::EmbeddingsModuleValidator" - area: "ai-features/embeddings" - ai_embeddings_selected_model: - type: enum - default: "" - allow_any: false - enum: "DiscourseAi::Configuration::EmbeddingDefsEnumerator" - validator: "DiscourseAi::Configuration::EmbeddingDefsValidator" - area: "ai-features/embeddings" - ai_embeddings_backfill_model: - type: enum - default: "" - allow_any: false - enum: "DiscourseAi::Configuration::EmbeddingDefsEnumerator" - hidden: true - ai_embeddings_per_post_enabled: - default: false - hidden: true - ai_embeddings_generate_for_pms: - default: false - area: "ai-features/embeddings" - ai_embeddings_semantic_related_topics_enabled: - default: false - client: true - area: "ai-features/embeddings" - ai_embeddings_semantic_related_topics: - default: 5 - area: "ai-features/embeddings" - ai_embeddings_semantic_related_include_closed_topics: - default: true - area: "ai-features/embeddings" - ai_embeddings_backfill_batch_size: - default: 250 - hidden: true - ai_embeddings_semantic_search_enabled: - default: false - client: true - validator: "DiscourseAi::Configuration::LlmDependencyValidator" - area: "ai-features/embeddings" - ai_embeddings_semantic_search_hyde_model: - default: "" - type: enum - allow_any: false - enum: "DiscourseAi::Configuration::LlmEnumerator" - validator: "DiscourseAi::Configuration::LlmValidator" - area: "ai-features/embeddings" - ai_embeddings_semantic_search_hyde_model_allowed_seeded_models: - default: "" - hidden: true - type: list - list_type: compact - ai_embeddings_semantic_quick_search_enabled: - default: false - client: true - hidden: true - area: "ai-features/embeddings" - ai_embeddings_semantic_search_hyde_persona: - default: "-32" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/embeddings" - - ai_embeddings_discourse_service_api_endpoint: - default: "" - hidden: true - ai_embeddings_discourse_service_api_endpoint_srv: - default: "" - hidden: true - ai_embeddings_discourse_service_api_key: - hidden: true - default: "" - secret: true - ai_embeddings_model: - hidden: true - type: enum - default: "bge-large-en" - allow_any: false - choices: - - all-mpnet-base-v2 - - text-embedding-ada-002 - - text-embedding-3-small - - text-embedding-3-large - - multilingual-e5-large - - bge-large-en - - gemini - - bge-m3 - ai_embeddings_pg_connection_string: - default: "" - hidden: true - - ai_summarization_enabled: - default: false - client: true - validator: "DiscourseAi::Configuration::LlmDependencyValidator" - area: "ai-features/summarization" - ai_summarization_model: - default: "" - allow_any: false - type: enum - enum: "DiscourseAi::Configuration::LlmEnumerator" - validator: "DiscourseAi::Configuration::LlmValidator" - hidden: true - ai_summarization_persona: - default: "-11" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/summarization" - ai_pm_summarization_allowed_groups: - type: group_list - list_type: compact - default: "" - area: "ai-features/summarization" - ai_custom_summarization_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01 - type: group_list - list_type: compact - default: "3|13" # 3: @staff, 13: @trust_level_3 - hidden: true - ai_summary_gists_enabled: - default: false - area: "ai-features/summarization" - ai_summary_gists_persona: - default: "-12" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/summarization" - ai_summary_gists_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01 - type: group_list - list_type: compact - default: "0" #everyone - hidden: true - ai_summarization_model_allowed_seeded_models: - default: "" - hidden: true - type: list - list_type: compact - ai_summary_backfill_topic_max_age_days: - default: 30 - min: 1 - max: 10000 - area: "ai-features/summarization" - ai_summary_backfill_maximum_topics_per_hour: - default: 0 - min: 0 - max: 10000 - area: "ai-features/summarization" - ai_summary_backfill_minimum_word_count: - default: 200 - area: "ai-features/summarization" - - ai_bot_enabled: - default: false - client: true - area: "ai-features/bot" - ai_bot_enable_chat_warning: - default: false - client: true - area: "ai-features/bot" - ai_bot_debugging_allowed_groups: - type: group_list - list_type: compact - default: "" - allow_any: false - area: "ai-features/bot" - ai_bot_allowed_groups: - type: group_list - list_type: compact - default: "3|14" # 3: @staff, 14: @trust_level_4 - area: "ai-features/bot" - ai_bot_public_sharing_allowed_groups: - client: false - type: group_list - list_type: compact - default: "1|2" # 1: admins, 2: moderators - allow_any: false - refresh: true - area: "ai-features/bot" - ai_bot_add_to_header: - default: true - client: true - area: "ai-features/bot" - ai_bot_github_access_token: - default: "" - secret: true - area: "ai-features/bot" - ai_bot_allowed_seeded_models: - default: "" - hidden: true - type: list - list_type: compact - area: "ai-features/bot" - ai_bot_discover_persona: - default: "" - type: enum - client: true - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/search" - ai_automation_max_triage_per_minute: - default: 60 - hidden: true - ai_automation_max_triage_per_post_per_minute: - default: 2 - hidden: true - ai_automation_allowed_seeded_models: - default: "" - hidden: true - type: list - list_type: compact - - ai_discord_search_enabled: - default: false - client: true - area: "ai-features/discord" - ai_discord_app_id: - default: "" - client: false - area: "ai-features/discord" - ai_discord_app_public_key: - default: "" - client: false - area: "ai-features/discord" - ai_discord_search_mode: - default: "search" - type: enum - choices: - - search - - persona - area: "ai-features/discord" - ai_discord_search_persona: - default: "" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/discord" - ai_discord_allowed_guilds: - type: list - list_type: compact - default: "" - area: "ai-features/discord" - - ai_spam_detection_enabled: - default: false - validator: "DiscourseAi::Configuration::SpamDetectionValidator" - ai_spam_detection_user_id: - default: "" - hidden: true - ai_spam_detection_model_allowed_seeded_models: - default: "" - hidden: true - type: list - - ai_rag_images_enabled: - default: false - hidden: true - - ai_bot_enable_dedicated_ux: - default: true - client: true - - ai_translation_enabled: - default: false - client: true - validator: "DiscourseAi::Configuration::LlmDependencyValidator" - area: "ai-features/translation" - ai_translation_model: - default: "" - type: enum - allow_any: false - enum: "DiscourseAi::Configuration::LlmEnumerator" - validator: "DiscourseAi::Configuration::LlmValidator" - area: "ai-features/translation" - ai_translation_locale_detector_persona: - default: "-27" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/translation" - ai_translation_post_raw_translator_persona: - default: "-28" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/translation" - ai_translation_topic_title_translator_persona: - default: "-29" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/translation" - ai_translation_short_text_translator_persona: - default: "-30" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/translation" - ai_translation_backfill_hourly_rate: - default: 0 - min: 0 - max: 1000 - client: false - hidden: true - area: "ai-features/translation" - ai_translation_backfill_limit_to_public_content: - default: true - client: false - area: "ai-features/translation" - ai_translation_max_post_length: - default: 10000 - client: false - area: "ai-features/translation" - ai_translation_backfill_max_age_days: - default: 5 - min: 0 - max: 20000 - client: false - area: "ai-features/translation" - ai_translation_verbose_logs: - default: false - client: false - hidden: true - area: "ai-features/translation" - - inferred_concepts_enabled: - default: false - client: true - area: "ai-features/inference" - inferred_concepts_background_match: - default: false - client: false - area: "ai-features/inference" - inferred_concepts_daily_topics_limit: - default: 20 - client: false - area: "ai-features/inference" - inferred_concepts_min_posts: - default: 5 - client: false - area: "ai-features/inference" - inferred_concepts_min_likes: - default: 10 - client: false - area: "ai-features/inference" - inferred_concepts_min_views: - default: 100 - client: false - area: "ai-features/inference" - inferred_concepts_lookback_days: - default: 30 - client: false - area: "ai-features/inference" - inferred_concepts_daily_posts_limit: - default: 30 - client: false - area: "ai-features/inference" - inferred_concepts_post_min_likes: - default: 5 - client: false - area: "ai-features/inference" - inferred_concepts_generate_persona: - default: "-15" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/inference" - inferred_concepts_match_persona: - default: "-16" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/inference" - inferred_concepts_deduplicate_persona: - default: "-17" - type: enum - enum: "DiscourseAi::Configuration::PersonaEnumerator" - area: "ai-features/inference" - ai_artifact_kv_value_max_length: - default: 5000 - hidden: true - ai_artifact_max_keys_per_user_per_artifact: - default: 100 - hidden: true diff --git a/db/fixtures/ai_bot/602_bot_users.rb b/db/fixtures/ai_bot/602_bot_users.rb deleted file mode 100644 index ae4d43f2..00000000 --- a/db/fixtures/ai_bot/602_bot_users.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots diff --git a/db/fixtures/personas/603_ai_personas.rb b/db/fixtures/personas/603_ai_personas.rb deleted file mode 100644 index 9a351b0c..00000000 --- a/db/fixtures/personas/603_ai_personas.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -summarization_personas = [DiscourseAi::Personas::Summarizer, DiscourseAi::Personas::ShortSummarizer] - -def from_setting(setting_name) - DB - .query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: setting_name, - ) - &.first - &.split("|") -end - -DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id| - persona = AiPersona.find_by(id: id) - if !persona - persona = AiPersona.new - persona.id = id - - if persona_class == DiscourseAi::Personas::WebArtifactCreator - # this is somewhat sensitive, so we default it to staff - persona.allowed_group_ids = [Group::AUTO_GROUPS[:staff]] - elsif summarization_personas.include?(persona_class) - # Copy group permissions from site settings. - default_groups = [Group::AUTO_GROUPS[:staff], Group::AUTO_GROUPS[:trust_level_3]] - - setting_name = "ai_custom_summarization_allowed_groups" - if persona_class == DiscourseAi::Personas::ShortSummarizer - setting_name = "ai_summary_gists_allowed_groups" - default_groups = [Group::AUTO_GROUPS[:everyone]] - end - - persona.allowed_group_ids = from_setting(setting_name) || default_groups - elsif persona_class == DiscourseAi::Personas::CustomPrompt - setting_name = "ai_helper_custom_prompts_allowed_groups" - default_groups = [Group::AUTO_GROUPS[:staff]] - persona.allowed_group_ids = from_setting(setting_name) || default_groups - elsif persona_class == DiscourseAi::Personas::ContentCreator - persona.allowed_group_ids = [Group::AUTO_GROUPS[:everyone]] - else - persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]] - end - - persona.enabled = persona_class.default_enabled - persona.priority = true if persona_class == DiscourseAi::Personas::General - end - - names = [ - persona_class.name, - persona_class.name + " 1", - persona_class.name + " 2", - persona_class.name + SecureRandom.hex, - ] - persona.name = DB.query_single(<<~SQL, names, id).first - SELECT guess_name - FROM ( - SELECT unnest(Array[?]) AS guess_name - FROM (SELECT 1) as t - ) x - LEFT JOIN ai_personas ON ai_personas.name = x.guess_name AND ai_personas.id <> ? - WHERE ai_personas.id IS NULL - ORDER BY x.guess_name ASC - LIMIT 1 - SQL - - persona.description = persona_class.description - - persona.system = true - instance = persona_class.new - tools = {} - instance.tools.map { |tool| tool.to_s.split("::").last }.each { |name| tools[name] = nil } - existing_tools = persona.tools || [] - - existing_tools.each do |tool| - if tool.is_a?(Array) - name, value = tool - tools[name] = value if tools.key?(name) - end - end - - persona.tools = tools.map { |name, value| [name, value] } - - persona.response_format = instance.response_format - persona.examples = instance.examples - - persona.system_prompt = instance.system_prompt - persona.top_p = instance.top_p - persona.temperature = instance.temperature - persona.save!(validate: false) -end diff --git a/db/migrate/20230224165056_create_classification_results_table.rb b/db/migrate/20230224165056_create_classification_results_table.rb deleted file mode 100644 index 4b6e8a57..00000000 --- a/db/migrate/20230224165056_create_classification_results_table.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -class CreateClassificationResultsTable < ActiveRecord::Migration[7.0] - def change - create_table :classification_results do |t| - t.string :model_used, null: true - t.string :classification_type, null: true - t.integer :target_id, null: true - t.string :target_type, null: true - - t.jsonb :classification, null: true - t.timestamps - end - - add_index :classification_results, - %i[target_id target_type model_used], - unique: true, - name: "unique_classification_target_per_type" - end -end diff --git a/db/migrate/20230307125342_created_model_accuracy_table.rb b/db/migrate/20230307125342_created_model_accuracy_table.rb deleted file mode 100644 index 3222bca5..00000000 --- a/db/migrate/20230307125342_created_model_accuracy_table.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class CreatedModelAccuracyTable < ActiveRecord::Migration[7.0] - def change - create_table :model_accuracies do |t| - t.string :model, null: false - t.string :classification_type, null: false - t.integer :flags_agreed, null: false, default: 0 - t.integer :flags_disagreed, null: false, default: 0 - - t.timestamps - end - - add_index :model_accuracies, %i[model], unique: true - end -end diff --git a/db/migrate/20230314184514_migrate_discourse_ai_reviewables.rb b/db/migrate/20230314184514_migrate_discourse_ai_reviewables.rb deleted file mode 100644 index 29d393e9..00000000 --- a/db/migrate/20230314184514_migrate_discourse_ai_reviewables.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true -class MigrateDiscourseAiReviewables < ActiveRecord::Migration[7.0] - def up - DB.exec("UPDATE reviewables SET type='ReviewableAiPost' WHERE type='ReviewableAIPost'") - DB.exec( - "UPDATE reviewables SET type='ReviewableAiChatMessage' WHERE type='ReviewableAIChatMessage'", - ) - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20230316160714_create_completion_prompt_table.rb b/db/migrate/20230316160714_create_completion_prompt_table.rb deleted file mode 100644 index a89c521d..00000000 --- a/db/migrate/20230316160714_create_completion_prompt_table.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true -class CreateCompletionPromptTable < ActiveRecord::Migration[7.0] - def change - create_table :completion_prompts do |t| - t.string :name, null: false - t.string :translated_name - t.integer :prompt_type, null: false, default: 0 - t.text :value, null: false - t.boolean :enabled, null: false, default: true - t.timestamps - end - - add_index :completion_prompts, %i[name], unique: true - end -end diff --git a/db/migrate/20230320122645_delete_duplicated_seeded_prompts.rb b/db/migrate/20230320122645_delete_duplicated_seeded_prompts.rb deleted file mode 100644 index 81d06821..00000000 --- a/db/migrate/20230320122645_delete_duplicated_seeded_prompts.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class DeleteDuplicatedSeededPrompts < ActiveRecord::Migration[7.0] - def up - DB.exec <<~SQL - DELETE FROM completion_prompts - WHERE ( - (id = 1 AND name = 'translate') OR - (id = 2 AND name = 'generate_titles') OR - (id = 3 AND name = 'proofread') - ) - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20230320185619_multi_message_completion_prompts.rb b/db/migrate/20230320185619_multi_message_completion_prompts.rb deleted file mode 100644 index 64f1bac4..00000000 --- a/db/migrate/20230320185619_multi_message_completion_prompts.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class MultiMessageCompletionPrompts < ActiveRecord::Migration[7.0] - def change - add_column :completion_prompts, :messages, :jsonb - end -end diff --git a/db/migrate/20230320191928_drop_completion_prompt_value.rb b/db/migrate/20230320191928_drop_completion_prompt_value.rb deleted file mode 100644 index a7864fd0..00000000 --- a/db/migrate/20230320191928_drop_completion_prompt_value.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class DropCompletionPromptValue < ActiveRecord::Migration[7.0] - def change - remove_column :completion_prompts, :value, :text - end -end diff --git a/db/migrate/20230322142028_make_dropped_value_column_nullable.rb b/db/migrate/20230322142028_make_dropped_value_column_nullable.rb deleted file mode 100644 index 988e618d..00000000 --- a/db/migrate/20230322142028_make_dropped_value_column_nullable.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true -class MakeDroppedValueColumnNullable < ActiveRecord::Migration[7.0] - def up - if column_exists?(:completion_prompts, :value) - Migration::SafeMigrate.disable! - change_column_null :completion_prompts, :value, true - Migration::SafeMigrate.enable! - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20230406135943_add_provider_to_completion_prompts.rb b/db/migrate/20230406135943_add_provider_to_completion_prompts.rb deleted file mode 100644 index 516419a7..00000000 --- a/db/migrate/20230406135943_add_provider_to_completion_prompts.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class AddProviderToCompletionPrompts < ActiveRecord::Migration[7.0] - def up - remove_index :completion_prompts, name: "index_completion_prompts_on_name" - add_column :completion_prompts, :provider, :text - add_index :completion_prompts, %i[name], unique: false - - # set provider for existing prompts - DB.exec <<~SQL - UPDATE completion_prompts - SET provider = 'openai' - WHERE provider IS NULL; - SQL - end - - def down - remove_column :completion_prompts, :provider - remove_index :completion_prompts, name: "index_completion_prompts_on_name" - add_index :completion_prompts, %i[name], unique: true - end -end diff --git a/db/migrate/20230424055354_create_ai_api_audit_logs.rb b/db/migrate/20230424055354_create_ai_api_audit_logs.rb deleted file mode 100644 index eb4c6413..00000000 --- a/db/migrate/20230424055354_create_ai_api_audit_logs.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class CreateAiApiAuditLogs < ActiveRecord::Migration[7.0] - def change - create_table :ai_api_audit_logs do |t| - t.integer :provider_id, null: false - t.integer :user_id - t.integer :request_tokens - t.integer :response_tokens - t.string :raw_request_payload - t.string :raw_response_payload - t.timestamps - end - end -end diff --git a/db/migrate/20230519003106_post_custom_prompts.rb b/db/migrate/20230519003106_post_custom_prompts.rb deleted file mode 100644 index 7d72cb0d..00000000 --- a/db/migrate/20230519003106_post_custom_prompts.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class PostCustomPrompts < ActiveRecord::Migration[7.0] - def change - create_table :post_custom_prompts do |t| - t.integer :post_id, null: false - t.json :custom_prompt, null: false - t.timestamps - end - - add_index :post_custom_prompts, :post_id, unique: true - end -end diff --git a/db/migrate/20230710171141_enable_pg_vector_extension.rb b/db/migrate/20230710171141_enable_pg_vector_extension.rb deleted file mode 100644 index a6acb319..00000000 --- a/db/migrate/20230710171141_enable_pg_vector_extension.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class EnablePgVectorExtension < ActiveRecord::Migration[7.0] - def change - begin - enable_extension :vector - rescue Exception => e - if DB.query_single("SELECT 1 FROM pg_available_extensions WHERE name = 'vector';").empty? - STDERR.puts "------------------------------DISCOURSE AI ERROR----------------------------------" - STDERR.puts " Discourse AI requires the pgvector extension on the PostgreSQL database." - STDERR.puts " Run a `./launcher rebuild app` to fix it on a standard install." - STDERR.puts " Alternatively, you can remove Discourse AI to rebuild." - STDERR.puts "------------------------------DISCOURSE AI ERROR----------------------------------" - end - raise e - end - end -end diff --git a/db/migrate/20230710171142_create_ai_topic_embeddings_table.rb b/db/migrate/20230710171142_create_ai_topic_embeddings_table.rb deleted file mode 100644 index 22756c1f..00000000 --- a/db/migrate/20230710171142_create_ai_topic_embeddings_table.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class CreateAiTopicEmbeddingsTable < ActiveRecord::Migration[7.0] - def change - create_table :ai_topic_embeddings_1_1, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(768)", null: false - t.timestamps - - t.index :topic_id, unique: true - end - - create_table :ai_topic_embeddings_2_1, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1536)", null: false - t.timestamps - - t.index :topic_id, unique: true - end - end -end diff --git a/db/migrate/20230710171143_migrate_embeddings_from_dedicated_database.rb b/db/migrate/20230710171143_migrate_embeddings_from_dedicated_database.rb deleted file mode 100644 index 3a60d99c..00000000 --- a/db/migrate/20230710171143_migrate_embeddings_from_dedicated_database.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -class MigrateEmbeddingsFromDedicatedDatabase < ActiveRecord::Migration[7.0] - def up - return unless SiteSetting.ai_embeddings_enabled - return if SiteSetting.ai_embeddings_pg_connection_string.blank? - - truncation = DiscourseAi::Embeddings::Strategies::Truncation.new - - vector_reps = - [ - DiscourseAi::Embeddings::VectorRepresentations::AllMpnetBaseV2, - DiscourseAi::Embeddings::VectorRepresentations::TextEmbeddingAda002, - ].map { |k| k.new(truncation) } - - vector_reps.each do |vector_rep| - new_table_name = DiscourseAi::Embeddings::Schema::TOPICS_TABLE - old_table_name = "topic_embeddings_#{vector_rep.name.underscore}" - - begin - row_count = - DiscourseAi::Database::Connection - .db - .query_single("SELECT COUNT(*) FROM #{old_table_name}") - .first - - if row_count > 0 - puts "Migrating #{row_count} embeddings from #{old_table_name} to #{new_table_name}" - - last_topic_id = 0 - - loop do - batch = DiscourseAi::Database::Connection.db.query(<<-SQL) - SELECT topic_id, embedding - FROM #{old_table_name} - WHERE topic_id > #{last_topic_id} - ORDER BY topic_id ASC - LIMIT 50 - SQL - break if batch.empty? - - DB.exec(<<-SQL) - INSERT INTO #{new_table_name} (topic_id, model_version, strategy_version, digest, embeddings, created_at, updated_at) - VALUES #{batch.map { |r| "(#{r.topic_id}, 0, 0, '', '#{r.embedding}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" }.join(", ")} - ON CONFLICT (topic_id) - DO NOTHING - SQL - - last_topic_id = batch.last.topic_id - end - end - rescue PG::Error => e - Rails.logger.error( - "Error #{e} migrating embeddings from #{old_table_name} to #{new_table_name}", - ) - end - end - end - - def down - # no-op - end -end diff --git a/db/migrate/20230727170222_create_multilingual_topic_embeddings_table.rb b/db/migrate/20230727170222_create_multilingual_topic_embeddings_table.rb deleted file mode 100644 index 4da6b5c4..00000000 --- a/db/migrate/20230727170222_create_multilingual_topic_embeddings_table.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class CreateMultilingualTopicEmbeddingsTable < ActiveRecord::Migration[7.0] - def change - create_table :ai_topic_embeddings_3_1, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :topic_id, unique: true - end - end -end diff --git a/db/migrate/20230831033812_rename_ai_helper_add_ai_pm_to_header_setting.rb b/db/migrate/20230831033812_rename_ai_helper_add_ai_pm_to_header_setting.rb deleted file mode 100644 index 1db79970..00000000 --- a/db/migrate/20230831033812_rename_ai_helper_add_ai_pm_to_header_setting.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class RenameAiHelperAddAiPmToHeaderSetting < ActiveRecord::Migration[7.0] - def up - execute "UPDATE site_settings SET name = 'ai_bot_add_to_header' WHERE name = 'ai_helper_add_ai_pm_to_header'" - end - - def down - execute "UPDATE site_settings SET name = 'ai_helper_add_ai_pm_to_header' WHERE name = 'ai_bot_add_to_header'" - end -end diff --git a/db/migrate/20231003155701_create_bge_topic_embeddings_table.rb b/db/migrate/20231003155701_create_bge_topic_embeddings_table.rb deleted file mode 100644 index e83e47e2..00000000 --- a/db/migrate/20231003155701_create_bge_topic_embeddings_table.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class CreateBgeTopicEmbeddingsTable < ActiveRecord::Migration[7.0] - def change - create_table :ai_topic_embeddings_4_1, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :topic_id, unique: true - end - end -end diff --git a/db/migrate/20231031050538_add_topic_id_post_id_to_ai_audit_log.rb b/db/migrate/20231031050538_add_topic_id_post_id_to_ai_audit_log.rb deleted file mode 100644 index 9f91cf59..00000000 --- a/db/migrate/20231031050538_add_topic_id_post_id_to_ai_audit_log.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class AddTopicIdPostIdToAiAuditLog < ActiveRecord::Migration[7.0] - def change - add_column :ai_api_audit_logs, :topic_id, :integer - add_column :ai_api_audit_logs, :post_id, :integer - end -end diff --git a/db/migrate/20231109011155_create_ai_personas.rb b/db/migrate/20231109011155_create_ai_personas.rb deleted file mode 100644 index da89d3ab..00000000 --- a/db/migrate/20231109011155_create_ai_personas.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true -# -class CreateAiPersonas < ActiveRecord::Migration[7.0] - def change - create_table :ai_personas do |t| - t.string :name, null: false, unique: true, limit: 100 - t.string :description, null: false, limit: 2000 - t.string :commands, array: true, default: [], null: false - t.string :system_prompt, null: false, limit: 10_000_000 - t.integer :allowed_group_ids, array: true, default: [], null: false - t.integer :created_by_id - t.boolean :enabled, default: true, null: false - t.timestamps - end - - add_index :ai_personas, :name, unique: true - end -end diff --git a/db/migrate/20231117050928_add_system_and_priority_to_ai_personas.rb b/db/migrate/20231117050928_add_system_and_priority_to_ai_personas.rb deleted file mode 100644 index 5c848ab6..00000000 --- a/db/migrate/20231117050928_add_system_and_priority_to_ai_personas.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class AddSystemAndPriorityToAiPersonas < ActiveRecord::Migration[7.0] - def change - add_column :ai_personas, :system, :boolean, null: false, default: false - add_column :ai_personas, :priority, :boolean, null: false, default: false - end -end diff --git a/db/migrate/20231120033747_remove_site_settings.rb b/db/migrate/20231120033747_remove_site_settings.rb deleted file mode 100644 index ea01a656..00000000 --- a/db/migrate/20231120033747_remove_site_settings.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true -class RemoveSiteSettings < ActiveRecord::Migration[7.0] - def up - DB.exec(<<~SQL, %w[ai_bot_enabled_chat_commands ai_bot_enabled_personas]) - DELETE FROM site_settings WHERE name IN (?) - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20231123224203_switch_to_generic_completion_prompts.rb b/db/migrate/20231123224203_switch_to_generic_completion_prompts.rb deleted file mode 100644 index 14dd61c1..00000000 --- a/db/migrate/20231123224203_switch_to_generic_completion_prompts.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class SwitchToGenericCompletionPrompts < ActiveRecord::Migration[7.0] - def change - remove_column :completion_prompts, :provider, :text - - DB.exec("DELETE FROM completion_prompts WHERE (id < 0 AND id > -300)") - end -end diff --git a/db/migrate/20231128151234_recreate_generate_titles_prompt.rb b/db/migrate/20231128151234_recreate_generate_titles_prompt.rb deleted file mode 100644 index 7153295a..00000000 --- a/db/migrate/20231128151234_recreate_generate_titles_prompt.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class RecreateGenerateTitlesPrompt < ActiveRecord::Migration[7.0] - def up - DB.exec("DELETE FROM completion_prompts WHERE id = -302") - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20231202013850_convert_ai_personas_commands_to_json.rb b/db/migrate/20231202013850_convert_ai_personas_commands_to_json.rb deleted file mode 100644 index 6894dd57..00000000 --- a/db/migrate/20231202013850_convert_ai_personas_commands_to_json.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true -class ConvertAiPersonasCommandsToJson < ActiveRecord::Migration[7.0] - def up - # this all may be a bit surprising, but interestingly this makes all our backend code - # cross compatible - # upgrading ["a", "b", "c"] to json simply works cause in both cases - # rails will cast to a string array and all code simply expects a string array - # - # this change was made so we can also start storing parameters with the commands - execute <<~SQL - ALTER TABLE ai_personas - ALTER COLUMN commands DROP DEFAULT - SQL - - execute <<~SQL - ALTER TABLE ai_personas - ALTER COLUMN commands - TYPE json USING array_to_json(commands) - SQL - - execute <<~SQL - ALTER TABLE ai_personas - ALTER COLUMN commands - SET DEFAULT '[]'::json - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20231227223301_create_gemini_topic_embeddings_table.rb b/db/migrate/20231227223301_create_gemini_topic_embeddings_table.rb deleted file mode 100644 index 04a532c1..00000000 --- a/db/migrate/20231227223301_create_gemini_topic_embeddings_table.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class CreateGeminiTopicEmbeddingsTable < ActiveRecord::Migration[7.0] - def change - create_table :ai_topic_embeddings_5_1, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(768)", null: false - t.timestamps - - t.index :topic_id, unique: true - end - end -end diff --git a/db/migrate/20231228213036_create_ai_post_embeddings_tables.rb b/db/migrate/20231228213036_create_ai_post_embeddings_tables.rb deleted file mode 100644 index f6ad3174..00000000 --- a/db/migrate/20231228213036_create_ai_post_embeddings_tables.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -class CreateAiPostEmbeddingsTables < ActiveRecord::Migration[7.0] - def change - create_table :ai_post_embeddings_1_1, id: false do |t| - t.integer :post_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(768)", null: false - t.timestamps - - t.index :post_id, unique: true - end - - create_table :ai_post_embeddings_2_1, id: false do |t| - t.integer :post_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1536)", null: false - t.timestamps - - t.index :post_id, unique: true - end - - create_table :ai_post_embeddings_3_1, id: false do |t| - t.integer :post_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :post_id, unique: true - end - - create_table :ai_post_embeddings_4_1, id: false do |t| - t.integer :post_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :post_id, unique: true - end - - create_table :ai_post_embeddings_5_1, id: false do |t| - t.integer :post_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(768)", null: false - t.timestamps - - t.index :post_id, unique: true - end - end -end diff --git a/db/migrate/20240104013944_add_params_to_completion_prompt.rb b/db/migrate/20240104013944_add_params_to_completion_prompt.rb deleted file mode 100644 index 7d179e13..00000000 --- a/db/migrate/20240104013944_add_params_to_completion_prompt.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class AddParamsToCompletionPrompt < ActiveRecord::Migration[7.0] - def change - add_column :completion_prompts, :temperature, :integer - add_column :completion_prompts, :stop_sequences, :string, array: true - end -end diff --git a/db/migrate/20240119152348_explicit_provider_backwards_compat.rb b/db/migrate/20240119152348_explicit_provider_backwards_compat.rb deleted file mode 100644 index 86ee7f16..00000000 --- a/db/migrate/20240119152348_explicit_provider_backwards_compat.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -class ExplicitProviderBackwardsCompat < ActiveRecord::Migration[7.0] - def up - backfill_settings("composer_ai_helper_enabled", "ai_helper_model") - backfill_settings( - "ai_embeddings_semantic_search_enabled", - "ai_embeddings_semantic_search_hyde_model", - ) - end - - def down - raise ActiveRecord::IrreversibleMigration - end - - def backfill_settings(feature_setting_name, llm_setting_name) - feature_enabled = - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: feature_setting_name, - ).first == "t" - - setting_value = - DB - .query_single( - "SELECT value FROM site_settings WHERE name = :llm_setting", - llm_setting: llm_setting_name, - ) - .first - .to_s - - providers = %w[aws_bedrock anthropic open_ai hugging_face vllm google] - # Sanity check to make sure we won't add provider twice. - return if providers.include?(setting_value.split(":").first) - - if !setting_value && feature_enabled - # Enabled and using old default (gpt-3.5-turbo) - DB.exec( - "UPDATE site_settings SET value='open_ai:gpt-3.5-turbo' WHERE name=:llm_setting", - llm_setting: llm_setting_name, - ) - elsif setting_value && !feature_enabled - # They'll have to choose an LLM model again before enabling the feature - DB.exec("DELETE FROM site_settings WHERE name=:llm_setting", llm_setting: llm_setting_name) - elsif setting_value && feature_enabled - DB.exec( - "UPDATE site_settings SET value=:new_value WHERE name=:llm_setting", - llm_setting: llm_setting_name, - new_value: append_provider(setting_value), - ) - end - end - - def append_provider(value) - open_ai_models = %w[gpt-3.5-turbo gpt-4 gpt-3.5-turbo-16k gpt-4-32k gpt-4-turbo gpt-4o] - return "open_ai:#{value}" if open_ai_models.include?(value) - return "google:#{value}" if value == "gemini-pro" - - hf_models = %w[StableBeluga2 Upstage-Llama-2-*-instruct-v2 Llama2-*-chat-hf Llama2-chat-hf] - return "hugging_face:#{value}" if hf_models.include?(value) - - # Models available through multiple providers - claude_models = %w[claude-instant-1 claude-2] - if claude_models.include?(value) - has_bedrock_creds = - DB.query_single( - "SELECT value FROM site_settings WHERE name = 'ai_bedrock_secret_access_key' OR name = 'ai_bedrock_access_key_id' ", - ).length > 0 - - if has_bedrock_creds - return "aws_bedrock:#{value}" - else - return "anthropic:#{value}" - end - end - - mixtral_models = %w[mistralai/Mixtral-8x7B-Instruct-v0.1 mistralai/Mistral-7B-Instruct-v0.2] - if mixtral_models.include?(value) - vllm_configured = - DB.query_single( - "SELECT value FROM site_settings WHERE name = 'ai_vllm_endpoint_srv' OR name = 'ai_vllm_endpoint' ", - ).length > 0 - - if vllm_configured - "vllm:#{value}" - else - "hugging_face:#{value}" - end - end - end -end diff --git a/db/migrate/20240126013358_create_openai_text_embedding_tables.rb b/db/migrate/20240126013358_create_openai_text_embedding_tables.rb deleted file mode 100644 index 6758ba17..00000000 --- a/db/migrate/20240126013358_create_openai_text_embedding_tables.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -class CreateOpenaiTextEmbeddingTables < ActiveRecord::Migration[7.0] - def change - create_table :ai_topic_embeddings_6_1, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1536)", null: false - t.timestamps - - t.index :topic_id, unique: true - end - - create_table :ai_topic_embeddings_7_1, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(2000)", null: false - t.timestamps - - t.index :topic_id, unique: true - end - - create_table :ai_post_embeddings_6_1, id: false do |t| - t.integer :post_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1536)", null: false - t.timestamps - - t.index :post_id, unique: true - end - - create_table :ai_post_embeddings_7_1, id: false do |t| - t.integer :post_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(2000)", null: false - t.timestamps - - t.index :post_id, unique: true - end - end -end diff --git a/db/migrate/20240202010752_add_temperature_top_p_to_ai_personas.rb b/db/migrate/20240202010752_add_temperature_top_p_to_ai_personas.rb deleted file mode 100644 index a101338f..00000000 --- a/db/migrate/20240202010752_add_temperature_top_p_to_ai_personas.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class AddTemperatureTopPToAiPersonas < ActiveRecord::Migration[7.0] - def change - add_column :ai_personas, :temperature, :float, null: true - add_column :ai_personas, :top_p, :float, null: true - end -end diff --git a/db/migrate/20240207144910_fix_llm_backed_setting_defaults.rb b/db/migrate/20240207144910_fix_llm_backed_setting_defaults.rb deleted file mode 100644 index 105ee15a..00000000 --- a/db/migrate/20240207144910_fix_llm_backed_setting_defaults.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -# Some sites defaults weren't migrated correctly due to the previous migration -# using the value as the if condition instead of checking with String#empty? -class FixLlmBackedSettingDefaults < ActiveRecord::Migration[7.0] - def up - backfill_settings("composer_ai_helper_enabled", "ai_helper_model") - backfill_settings( - "ai_embeddings_semantic_search_enabled", - "ai_embeddings_semantic_search_hyde_model", - ) - end - - def down - raise ActiveRecord::IrreversibleMigration - end - - def backfill_settings(feature_setting_name, llm_setting_name) - feature_enabled = - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: feature_setting_name, - ).first == "t" - - setting_value = - DB - .query_single( - "SELECT value FROM site_settings WHERE name = :llm_setting", - llm_setting: llm_setting_name, - ) - .first - .to_s - using_old_default = setting_value.empty? - - providers = %w[aws_bedrock anthropic open_ai hugging_face vllm google] - # Sanity check to make sure we won't add provider twice. - return if providers.include?(setting_value.split(":").first) - - if using_old_default && feature_enabled - # Enabled and using old default (gpt-3.5-turbo) - DB.exec(<<~SQL, llm_setting: llm_setting_name, default: "open_ai:gpt-3.5-turbo") - INSERT INTO site_settings(name, data_type, value, created_at, updated_at) - VALUES (:llm_setting, 1, :default, NOW(), NOW()) - SQL - end - end -end diff --git a/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb b/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb deleted file mode 100644 index cf5e0315..00000000 --- a/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true -# -class AddUserIdMentionableDefaultLlmToAiPersonas < ActiveRecord::Migration[7.0] - def change - change_table :ai_personas do |t| - t.integer :user_id, null: true - t.boolean :mentionable, default: false, null: false - t.text :default_llm, null: true, length: 250 - end - end -end diff --git a/db/migrate/20240213051213_add_limits_to_ai_persona.rb b/db/migrate/20240213051213_add_limits_to_ai_persona.rb deleted file mode 100644 index fac906e4..00000000 --- a/db/migrate/20240213051213_add_limits_to_ai_persona.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AddLimitsToAiPersona < ActiveRecord::Migration[7.0] - def change - change_table :ai_personas do |t| - t.integer :max_context_posts, null: true - end - end -end diff --git a/db/migrate/20240309034751_add_shared_ai_conversations.rb b/db/migrate/20240309034751_add_shared_ai_conversations.rb deleted file mode 100644 index 37e910c7..00000000 --- a/db/migrate/20240309034751_add_shared_ai_conversations.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class AddSharedAiConversations < ActiveRecord::Migration[7.0] - def up - create_table :shared_ai_conversations do |t| - t.integer :user_id, null: false - t.integer :target_id, null: false - t.string :target_type, null: false, max_length: 100 - t.string :title, null: false, max_length: 1024 - t.string :llm_name, null: false, max_length: 1024 - t.jsonb :context, null: false - t.string :share_key, null: false, index: { unique: true } - t.string :excerpt, null: false, max_length: 10_000 - t.timestamps - end - - add_index :shared_ai_conversations, %i[target_id target_type], unique: true - add_index :shared_ai_conversations, - %i[user_id target_id target_type], - unique: true, - name: "idx_shared_ai_conversations_user_target" - end - - def down - drop_table :shared_ai_conversations - end -end diff --git a/db/migrate/20240309034752_create_rag_document_fragment_table.rb b/db/migrate/20240309034752_create_rag_document_fragment_table.rb deleted file mode 100644 index a6a3e233..00000000 --- a/db/migrate/20240309034752_create_rag_document_fragment_table.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class CreateRagDocumentFragmentTable < ActiveRecord::Migration[7.0] - def change - create_table :rag_document_fragments do |t| - t.text :fragment, null: false - t.integer :upload_id, null: false - t.integer :ai_persona_id, null: false - t.integer :fragment_number, null: false - t.timestamps - end - end -end diff --git a/db/migrate/20240313165121_embedding_tables_for_rag_uploads.rb b/db/migrate/20240313165121_embedding_tables_for_rag_uploads.rb deleted file mode 100644 index 0aa7d502..00000000 --- a/db/migrate/20240313165121_embedding_tables_for_rag_uploads.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -class EmbeddingTablesForRagUploads < ActiveRecord::Migration[7.0] - def change - create_table :ai_document_fragment_embeddings_1_1, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(768)", null: false - t.timestamps - - t.index :rag_document_fragment_id, - unique: true, - name: "rag_document_fragment_id_embeddings_1_1" - end - - create_table :ai_document_fragment_embeddings_2_1, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1536)", null: false - t.timestamps - - t.index :rag_document_fragment_id, - unique: true, - name: "rag_document_fragment_id_embeddings_2_1" - end - - create_table :ai_document_fragment_embeddings_3_1, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :rag_document_fragment_id, - unique: true, - name: "rag_document_fragment_id_embeddings_3_1" - end - - create_table :ai_document_fragment_embeddings_4_1, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :rag_document_fragment_id, - unique: true, - name: "rag_document_fragment_id_embeddings_4_1" - end - - create_table :ai_document_fragment_embeddings_5_1, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(768)", null: false - t.timestamps - - t.index :rag_document_fragment_id, - unique: true, - name: "rag_document_fragment_id_embeddings_5_1" - end - - create_table :ai_document_fragment_embeddings_6_1, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1536)", null: false - t.timestamps - - t.index :rag_document_fragment_id, - unique: true, - name: "rag_document_fragment_id_embeddings_6_1" - end - - create_table :ai_document_fragment_embeddings_7_1, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(2000)", null: false - t.timestamps - - t.index :rag_document_fragment_id, - unique: true, - name: "rag_document_fragment_id_embeddings_7_1" - end - end -end diff --git a/db/migrate/20240322035907_add_images_to_ai_personas.rb b/db/migrate/20240322035907_add_images_to_ai_personas.rb deleted file mode 100644 index b78b1c33..00000000 --- a/db/migrate/20240322035907_add_images_to_ai_personas.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class AddImagesToAiPersonas < ActiveRecord::Migration[7.0] - def change - change_table :ai_personas do |t| - add_column :ai_personas, :vision_enabled, :boolean, default: false, null: false - add_column :ai_personas, :vision_max_pixels, :integer, default: 1_048_576, null: false - end - end -end diff --git a/db/migrate/20240404000838_add_metadata_to_rag_document_frament.rb b/db/migrate/20240404000838_add_metadata_to_rag_document_frament.rb deleted file mode 100644 index 9614979d..00000000 --- a/db/migrate/20240404000838_add_metadata_to_rag_document_frament.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class AddMetadataToRagDocumentFrament < ActiveRecord::Migration[7.0] - def change - # limit is purely for safety - add_column :rag_document_fragments, :metadata, :text, null: true, limit: 100_000 - end -end diff --git a/db/migrate/20240409035951_add_rag_params_to_ai_persona.rb b/db/migrate/20240409035951_add_rag_params_to_ai_persona.rb deleted file mode 100644 index 2cb2d5b2..00000000 --- a/db/migrate/20240409035951_add_rag_params_to_ai_persona.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class AddRagParamsToAiPersona < ActiveRecord::Migration[7.0] - def change - # the default fits without any data loss in a 384 token vector representation - # larger embedding models can easily fit larger chunks so this is configurable - add_column :ai_personas, :rag_chunk_tokens, :integer, null: false, default: 374 - add_column :ai_personas, :rag_chunk_overlap_tokens, :integer, null: false, default: 10 - add_column :ai_personas, :rag_conversation_chunks, :integer, null: false, default: 10 - end -end diff --git a/db/migrate/20240410170000_add_embeddings_tablesfor_bge_m3.rb b/db/migrate/20240410170000_add_embeddings_tablesfor_bge_m3.rb deleted file mode 100644 index 95ecd0c5..00000000 --- a/db/migrate/20240410170000_add_embeddings_tablesfor_bge_m3.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -class AddEmbeddingsTablesforBgeM3 < ActiveRecord::Migration[7.0] - def change - create_table :ai_topic_embeddings_8_1, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :topic_id, unique: true - end - create_table :ai_post_embeddings_8_1, id: false do |t| - t.integer :post_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :post_id, unique: true - end - create_table :ai_document_fragment_embeddings_8_1, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_version, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "vector(1024)", null: false - t.timestamps - - t.index :rag_document_fragment_id, - unique: true, - name: "rag_document_fragment_id_embeddings_8_1" - end - end -end diff --git a/db/migrate/20240424220101_add_auto_image_caption_to_user_options.rb b/db/migrate/20240424220101_add_auto_image_caption_to_user_options.rb deleted file mode 100644 index 329d2204..00000000 --- a/db/migrate/20240424220101_add_auto_image_caption_to_user_options.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddAutoImageCaptionToUserOptions < ActiveRecord::Migration[7.0] - def change - add_column :user_options, :auto_image_caption, :boolean, default: false, null: false - end -end diff --git a/db/migrate/20240429065155_add_consolidated_question_llm_to_ai_persona.rb b/db/migrate/20240429065155_add_consolidated_question_llm_to_ai_persona.rb deleted file mode 100644 index 963dffad..00000000 --- a/db/migrate/20240429065155_add_consolidated_question_llm_to_ai_persona.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddConsolidatedQuestionLlmToAiPersona < ActiveRecord::Migration[7.0] - def change - add_column :ai_personas, :question_consolidator_llm, :text, max_length: 2000 - end -end diff --git a/db/migrate/20240503034946_add_allow_chat_to_ai_persona.rb b/db/migrate/20240503034946_add_allow_chat_to_ai_persona.rb deleted file mode 100644 index cfba263b..00000000 --- a/db/migrate/20240503034946_add_allow_chat_to_ai_persona.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddAllowChatToAiPersona < ActiveRecord::Migration[7.0] - def change - add_column :ai_personas, :allow_chat, :boolean, default: false, null: false - end -end diff --git a/db/migrate/20240503042558_add_chat_message_custom_prompt.rb b/db/migrate/20240503042558_add_chat_message_custom_prompt.rb deleted file mode 100644 index aceb7c79..00000000 --- a/db/migrate/20240503042558_add_chat_message_custom_prompt.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true -class AddChatMessageCustomPrompt < ActiveRecord::Migration[7.0] - def change - create_table :chat_message_custom_prompts do |t| - t.bigint :message_id, null: false - t.json :custom_prompt, null: false - t.timestamps - end - - add_index :chat_message_custom_prompts, :message_id, unique: true - end -end diff --git a/db/migrate/20240504222307_create_llm_model_table.rb b/db/migrate/20240504222307_create_llm_model_table.rb deleted file mode 100644 index 96bcc3dd..00000000 --- a/db/migrate/20240504222307_create_llm_model_table.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class CreateLlmModelTable < ActiveRecord::Migration[7.0] - def change - create_table :llm_models do |t| - t.string :display_name - t.string :name, null: false - t.string :provider, null: false - t.string :tokenizer, null: false - t.integer :max_prompt_tokens, null: false - t.timestamps - end - end -end diff --git a/db/migrate/20240514001334_add_feature_name_to_ai_api_audit_log.rb b/db/migrate/20240514001334_add_feature_name_to_ai_api_audit_log.rb deleted file mode 100644 index 1f1f08dc..00000000 --- a/db/migrate/20240514001334_add_feature_name_to_ai_api_audit_log.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddFeatureNameToAiApiAuditLog < ActiveRecord::Migration[7.0] - def change - add_column :ai_api_audit_logs, :feature_name, :string, limit: 255 - end -end diff --git a/db/migrate/20240514171609_add_endpoint_data_to_llm_model.rb b/db/migrate/20240514171609_add_endpoint_data_to_llm_model.rb deleted file mode 100644 index 7be5e9d0..00000000 --- a/db/migrate/20240514171609_add_endpoint_data_to_llm_model.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class AddEndpointDataToLlmModel < ActiveRecord::Migration[7.0] - def change - add_column :llm_models, :url, :string - add_column :llm_models, :api_key, :string - end -end diff --git a/db/migrate/20240527054218_add_language_model_to_ai_audit_logs.rb b/db/migrate/20240527054218_add_language_model_to_ai_audit_logs.rb deleted file mode 100644 index 5f639394..00000000 --- a/db/migrate/20240527054218_add_language_model_to_ai_audit_logs.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class AddLanguageModelToAiAuditLogs < ActiveRecord::Migration[7.0] - def change - add_column :ai_api_audit_logs, :language_model, :string, limit: 255 - end -end diff --git a/db/migrate/20240528132059_add_companion_user_to_llm_model.rb b/db/migrate/20240528132059_add_companion_user_to_llm_model.rb deleted file mode 100644 index e512e338..00000000 --- a/db/migrate/20240528132059_add_companion_user_to_llm_model.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class AddCompanionUserToLlmModel < ActiveRecord::Migration[7.0] - def change - add_column :llm_models, :user_id, :integer - add_column :llm_models, :enabled_chat_bot, :boolean, null: false, default: false - end -end diff --git a/db/migrate/20240528144216_seed_open_ai_models.rb b/db/migrate/20240528144216_seed_open_ai_models.rb deleted file mode 100644 index 0d6fde0a..00000000 --- a/db/migrate/20240528144216_seed_open_ai_models.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -class SeedOpenAiModels < ActiveRecord::Migration[7.0] - def up - models = [] - - open_ai_api_key = fetch_setting("ai_openai_api_key") - enabled_models = fetch_setting("ai_bot_enabled_chat_bots")&.split("|").to_a - enabled_models = ["gpt-3.5-turbo-16k"] if enabled_models.empty? - - if open_ai_api_key.present? - models << mirror_open_ai( - "GPT-3.5-Turbo", - "gpt-3.5-turbo", - 8192, - "ai_openai_gpt35_url", - open_ai_api_key, - -111, - enabled_models, - ) - models << mirror_open_ai( - "GPT-3.5-Turbo-16K", - "gpt-3.5-turbo-16k", - 16_384, - "ai_openai_gpt35_16k_url", - open_ai_api_key, - -111, - enabled_models, - ) - models << mirror_open_ai( - "GPT-4", - "gpt-4", - 8192, - "ai_openai_gpt4_url", - open_ai_api_key, - -110, - enabled_models, - ) - models << mirror_open_ai( - "GPT-4-32K", - "gpt-4-32k", - 32_768, - "ai_openai_gpt4_32k_url", - open_ai_api_key, - -110, - enabled_models, - ) - models << mirror_open_ai( - "GPT-4-Turbo", - "gpt-4-turbo", - 131_072, - "ai_openai_gpt4_turbo_url", - open_ai_api_key, - -113, - enabled_models, - ) - models << mirror_open_ai( - "GPT-4o", - "gpt-4o", - 131_072, - "ai_openai_gpt4o_url", - open_ai_api_key, - -121, - enabled_models, - ) - end - - if models.present? - rows = models.compact.join(", ") - - DB.exec(<<~SQL) if rows.present? - INSERT INTO llm_models(display_name, name, provider, tokenizer, max_prompt_tokens, url, api_key, user_id, enabled_chat_bot, created_at, updated_at) - VALUES #{rows}; - SQL - end - end - - def has_companion_user?(user_id) - DB.query_single("SELECT id FROM users WHERE id = :user_id", user_id: user_id).first.present? - end - - def fetch_setting(name) - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: name, - ).first - end - - def mirror_open_ai( - display_name, - name, - max_prompt_tokens, - setting_name, - key, - bot_id, - enabled_models - ) - url = fetch_setting(setting_name) || "https://api.openai.com/v1/chat/completions" - user_id = has_companion_user?(bot_id) ? bot_id : "NULL" - enabled = enabled_models.include?(name) - - "('#{display_name}', '#{name}', 'open_ai', 'DiscourseAi::Tokenizer::OpenAiTokenizer', #{max_prompt_tokens}, '#{url}', '#{key}', #{user_id}, #{enabled}, NOW(), NOW())" - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240531205234_seed_claude_models.rb b/db/migrate/20240531205234_seed_claude_models.rb deleted file mode 100644 index 4b6dad40..00000000 --- a/db/migrate/20240531205234_seed_claude_models.rb +++ /dev/null @@ -1,109 +0,0 @@ -# frozen_string_literal: true - -class SeedClaudeModels < ActiveRecord::Migration[7.0] - def up - claude_models = %w[claude-instant-1 claude-2 claude-3-haiku claude-3-sonnet claude-3-opus] - - models = [] - - bedrock_secret_access_key = fetch_setting("ai_bedrock_secret_access_key") - enabled_models = fetch_setting("ai_bot_enabled_chat_bots")&.split("|").to_a - - if bedrock_secret_access_key.present? - bedrock_region = fetch_setting("ai_bedrock_region") || "us-east-1" - - claude_models.each do |cm| - url = - "https://bedrock-runtime.#{bedrock_region}.amazonaws.com/model/#{mapped_bedrock_model(cm)}/invoke" - - bot_id = claude_bot_id(cm) - user_id = has_companion_user?(bot_id) ? bot_id : "NULL" - - enabled = enabled_models.include?(cm) - models << "('#{display_name(cm)}', '#{cm}', 'aws_bedrock', 'DiscourseAi::Tokenizer::AnthropicTokenizer', 200000, '#{url}', '#{bedrock_secret_access_key}', #{user_id}, #{enabled}, NOW(), NOW())" - end - end - - anthropic_ai_api_key = fetch_setting("ai_anthropic_api_key") - if anthropic_ai_api_key.present? - claude_models.each do |cm| - url = "https://api.anthropic.com/v1/messages" - - bot_id = claude_bot_id(cm) - user_id = has_companion_user?(bot_id) ? bot_id : "NULL" - - enabled = enabled_models.include?(cm) - models << "('#{display_name(cm)}', '#{cm}', 'anthropic', 'DiscourseAi::Tokenizer::AnthropicTokenizer', 200000, '#{url}', '#{anthropic_ai_api_key}', #{user_id}, #{enabled}, NOW(), NOW())" - end - end - - if models.present? - rows = models.compact.join(", ") - - DB.exec(<<~SQL, rows: rows) if rows.present? - INSERT INTO llm_models(display_name, name, provider, tokenizer, max_prompt_tokens, url, api_key, user_id, enabled_chat_bot, created_at, updated_at) - VALUES #{rows}; - SQL - end - end - - def has_companion_user?(user_id) - DB.query_single("SELECT id FROM users WHERE id = :user_id", user_id: user_id).first.present? - end - - def fetch_setting(name) - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: name, - ).first - end - - def claude_bot_id(model) - case model - when "claude-2" - -112 - when "claude-3-haiku" - -119 - when "claude-3-sonnet" - -118 - when "claude-instant-1" - nil - when "claude-3-opus" - -117 - end - end - - def mapped_bedrock_model(model) - case model - when "claude-2" - "anthropic.claude-v2:1" - when "claude-3-haiku" - "anthropic.claude-3-haiku-20240307-v1:0" - when "claude-3-sonnet" - "anthropic.claude-3-sonnet-20240229-v1:0" - when "claude-instant-1" - "anthropic.claude-instant-v1" - when "claude-3-opus" - "anthropic.claude-3-opus-20240229-v1:0" - end - end - - def display_name(model) - case model - when "claude-2" - "Claude 2" - when "claude-3-haiku" - "Claude 3 Haiku" - when "claude-3-sonnet" - "Claude 3 Sonnet" - when "claude-instant-1" - "Claude Instant 1" - when "claude-3-opus" - "Claude 3 Opus" - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240603133432_seed_other_propietary_models.rb b/db/migrate/20240603133432_seed_other_propietary_models.rb deleted file mode 100644 index 085ca41a..00000000 --- a/db/migrate/20240603133432_seed_other_propietary_models.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -class SeedOtherPropietaryModels < ActiveRecord::Migration[7.0] - def up - models = [] - - gemini_key = fetch_setting("ai_gemini_api_key") - enabled_models = fetch_setting("ai_bot_enabled_chat_bots")&.split("|").to_a - - if gemini_key.present? - gemini_models = %w[gemini-pro gemini-1.5-pro gemini-1.5-flash] - - gemini_models.each do |gm| - url = "https://generativelanguage.googleapis.com/v1beta/models/#{gemini_mapped_model(gm)}" - - bot_user_id = "NULL" - bot_user_id = -115 if gm == "gemini-1.5-pro" && has_companion_user?(-115) - - enabled = enabled_models.include?(gm) - models << "('#{gm.titleize}', '#{gm}', 'google', 'DiscourseAi::Tokenizer::OpenAiTokenizer', '#{gemini_tokens(gm)}', '#{url}', '#{gemini_key}', #{bot_user_id}, #{enabled}, NOW(), NOW())" - end - end - - cohere_key = fetch_setting("ai_cohere_api_key") - - if cohere_key.present? - cohere_models = %w[command-light command command-r command-r-plus] - - cohere_models.each do |cm| - bot_user_id = "NULL" - bot_user_id = -120 if cm == "command-r-plus" && has_companion_user?(-120) - - enabled = enabled_models.include?(cm) - models << "('#{cm.titleize}', '#{cm}', 'cohere', 'DiscourseAi::Tokenizer::OpenAiTokenizer', #{cohere_tokens(cm)}, 'https://api.cohere.ai/v1/chat', '#{cohere_key}', #{bot_user_id}, #{enabled}, NOW(), NOW())" - end - end - - if models.present? - rows = models.compact.join(", ") - - DB.exec(<<~SQL, rows: rows) if rows.present? - INSERT INTO llm_models(display_name, name, provider, tokenizer, max_prompt_tokens, url, api_key, user_id, enabled_chat_bot, created_at, updated_at) - VALUES #{rows}; - SQL - end - end - - def has_companion_user?(user_id) - DB.query_single("SELECT id FROM users WHERE id = :user_id", user_id: user_id).first.present? - end - - def fetch_setting(name) - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: name, - ).first - end - - def cohere_tokens(model) - case model - when "command-light" - 4096 - when "command" - 8192 - when "command-r" - 131_072 - when "command-r-plus" - 131_072 - else - 8192 - end - end - - def gemini_mapped_model(model) - case model - when "gemini-1.5-pro" - "gemini-1.5-pro-latest" - when "gemini-1.5-flash" - "gemini-1.5-flash-latest" - else - "gemini-pro-latest" - end - end - - def gemini_tokens(model) - if model.start_with?("gemini-1.5") - # technically we support 1 million tokens, but we're being conservative - 800_000 - else - 16_384 # 50% of model tokens - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240603143158_seed_oss_models.rb b/db/migrate/20240603143158_seed_oss_models.rb deleted file mode 100644 index a528fac9..00000000 --- a/db/migrate/20240603143158_seed_oss_models.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -class SeedOssModels < ActiveRecord::Migration[7.0] - def up - models = [] - enabled_models = fetch_setting("ai_bot_enabled_chat_bots")&.split("|").to_a - enabled = enabled_models.include?("mixtral-8x7B-Instruct-V0.1") - - hf_key = fetch_setting("ai_hugging_face_api_key") - hf_url = fetch_setting("ai_hugging_face_api_url") - - user_id = has_companion_user?(-114) ? -114 : "NULL" - - if hf_url.present? && hf_key.present? - hf_token_limit = fetch_setting("ai_hugging_face_token_limit") - hf_display_name = fetch_setting("ai_hugging_face_model_display_name") - - name = hf_display_name || "mistralai/Mixtral" - token_limit = hf_token_limit || 32_000 - - models << "('#{name}', '#{name}', 'hugging_face', 'DiscourseAi::Tokenizer::MixtralTokenizer', #{token_limit}, '#{hf_url}', '#{hf_key}', #{user_id}, #{enabled}, NOW(), NOW())" - end - - vllm_key = fetch_setting("ai_vllm_api_key") - vllm_url = fetch_setting("ai_vllm_endpoint") - - if vllm_key.present? && vllm_url.present? - url = "#{vllm_url}/v1/chat/completions" - name = "mistralai/Mixtral" - - models << "('#{name}', '#{name}', 'vllm', 'DiscourseAi::Tokenizer::MixtralTokenizer', 32000, '#{url}', '#{vllm_key}', #{user_id}, #{enabled}, NOW(), NOW())" - end - - vllm_srv = fetch_setting("ai_vllm_endpoint_srv") - srv_reserved_url = "https://vllm.shadowed-by-srv.invalid" - - srv_record = - DB.query_single( - "SELECT id FROM llm_models WHERE url = :reserved", - reserved: srv_reserved_url, - ).first - - if vllm_srv.present? && srv_record.nil? - url = "https://vllm.shadowed-by-srv.invalid" - name = "mistralai/Mixtral" - - models << "('vLLM SRV LLM', '#{name}', 'vllm', 'DiscourseAi::Tokenizer::MixtralTokenizer', 32000, '#{url}', '#{vllm_key}', #{user_id}, #{enabled}, NOW(), NOW())" - end - - if models.present? - rows = models.compact.join(", ") - - DB.exec(<<~SQL, rows: rows) if rows.present? - INSERT INTO llm_models(display_name, name, provider, tokenizer, max_prompt_tokens, url, api_key, user_id, enabled_chat_bot, created_at, updated_at) - VALUES #{rows}; - SQL - end - end - - def has_companion_user?(user_id) - DB.query_single("SELECT id FROM users WHERE id = :user_id", user_id: user_id).first.present? - end - - def fetch_setting(name) - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: name, - ).first - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240606151348_create_ai_summaries_table.rb b/db/migrate/20240606151348_create_ai_summaries_table.rb deleted file mode 100644 index bcace654..00000000 --- a/db/migrate/20240606151348_create_ai_summaries_table.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class CreateAiSummariesTable < ActiveRecord::Migration[7.0] - def change - create_table :ai_summaries do |t| - t.integer :target_id, null: false - t.string :target_type, null: false - t.int4range :content_range - t.string :summarized_text, null: false - t.string :original_content_sha, null: false - t.string :algorithm, null: false - t.timestamps - end - end -end diff --git a/db/migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb b/db/migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb deleted file mode 100644 index 326f78cc..00000000 --- a/db/migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class CopySummarySectionsToAiSummaries < ActiveRecord::Migration[7.0] - def up - execute <<-SQL - INSERT INTO ai_summaries (id, target_id, target_type, content_range, summarized_text, original_content_sha, algorithm, created_at, updated_at) - SELECT id, target_id, target_type, content_range, summarized_text, original_content_sha, algorithm, created_at, updated_at - FROM summary_sections - WHERE meta_section_id IS NULL - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240609061418_tool_details_and_command_removal.rb b/db/migrate/20240609061418_tool_details_and_command_removal.rb deleted file mode 100644 index f4db4f75..00000000 --- a/db/migrate/20240609061418_tool_details_and_command_removal.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class ToolDetailsAndCommandRemoval < ActiveRecord::Migration[7.0] - def change - add_column :ai_personas, :tool_details, :boolean, default: true, null: false - add_column :ai_personas, :tools, :json, null: false, default: [] - # we can not do this cause we are seeding the data and in certain - # build scenarios we seed prior to running post migrations - # this risks potentially dropping data but the window is small - # Migration::ColumnDropper.mark_readonly(:ai_personas, :commands) - - execute <<~SQL - UPDATE ai_personas - SET tools = commands - SQL - end -end diff --git a/db/migrate/20240609232736_drop_commands_from_ai_personas.rb b/db/migrate/20240609232736_drop_commands_from_ai_personas.rb deleted file mode 100644 index 1a3ae897..00000000 --- a/db/migrate/20240609232736_drop_commands_from_ai_personas.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true -class DropCommandsFromAiPersonas < ActiveRecord::Migration[7.0] - def down - raise ActiveRecord::IrreversibleMigration - end - - def up - Migration::ColumnDropper.execute_drop(:ai_personas, [:commands]) - end -end diff --git a/db/migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb b/db/migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb deleted file mode 100644 index bbad5955..00000000 --- a/db/migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class CopySummarizationStrategyToAiSummarizationStrategy < ActiveRecord::Migration[7.0] - def up - execute <<-SQL - UPDATE site_settings - SET data_type = (SELECT data_type FROM site_settings WHERE name = 'summarization_strategy'), - value = (SELECT value FROM site_settings WHERE name = 'summarization_strategy') - WHERE name = 'ai_summarization_strategy' - AND EXISTS (SELECT 1 FROM site_settings WHERE name = 'summarization_strategy'); - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb b/db/migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb deleted file mode 100644 index ae28e389..00000000 --- a/db/migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class CopyCustomSummarizationAllowedGroupsToAiCustomSummarizationAllowedGroups < ActiveRecord::Migration[ - 7.0 -] - def up - execute <<-SQL - UPDATE site_settings - SET data_type = (SELECT data_type FROM site_settings WHERE name = 'custom_summarization_allowed_groups'), - value = (SELECT value FROM site_settings WHERE name = 'custom_summarization_allowed_groups') - WHERE name = 'ai_custom_summarization_allowed_groups' - AND EXISTS (SELECT 1 FROM site_settings WHERE name = 'custom_summarization_allowed_groups'); - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240611170904_upgrade_pgvector_070.rb b/db/migrate/20240611170904_upgrade_pgvector_070.rb deleted file mode 100644 index 875b5362..00000000 --- a/db/migrate/20240611170904_upgrade_pgvector_070.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class UpgradePgvector070 < ActiveRecord::Migration[7.0] - def up - minimum_target_version = "0.7.0" - installed_version = - DB.query_single("SELECT extversion FROM pg_extension WHERE extname = 'vector';").first - - if Gem::Version.new(installed_version) < Gem::Version.new(minimum_target_version) - DB.exec("ALTER EXTENSION vector UPDATE TO '0.7.0';") - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240611170905_move_embeddings_to_single_table_per_type.rb b/db/migrate/20240611170905_move_embeddings_to_single_table_per_type.rb deleted file mode 100644 index fad7070b..00000000 --- a/db/migrate/20240611170905_move_embeddings_to_single_table_per_type.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -class MoveEmbeddingsToSingleTablePerType < ActiveRecord::Migration[7.0] - def up - create_table :ai_topic_embeddings, id: false do |t| - t.integer :topic_id, null: false - t.integer :model_id, null: false - t.integer :model_version, null: false - t.integer :strategy_id, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "halfvec", null: false - t.timestamps - - t.index %i[model_id strategy_id topic_id], - unique: true, - name: "index_ai_topic_embeddings_on_model_strategy_topic" - end - - create_table :ai_post_embeddings, id: false do |t| - t.integer :post_id, null: false - t.integer :model_id, null: false - t.integer :model_version, null: false - t.integer :strategy_id, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "halfvec", null: false - t.timestamps - - t.index %i[model_id strategy_id post_id], - unique: true, - name: "index_ai_post_embeddings_on_model_strategy_post" - end - - create_table :ai_document_fragment_embeddings, id: false do |t| - t.integer :rag_document_fragment_id, null: false - t.integer :model_id, null: false - t.integer :model_version, null: false - t.integer :strategy_id, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "halfvec", null: false - t.timestamps - - t.index %i[model_id strategy_id rag_document_fragment_id], - unique: true, - name: "index_ai_fragment_embeddings_on_model_strategy_fragment" - end - - # Copy data from old tables to new tables - execute <<-SQL - INSERT INTO ai_topic_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT topic_id, 1, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_topic_embeddings_1_1; - - INSERT INTO ai_topic_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT topic_id, 2, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_topic_embeddings_2_1; - - INSERT INTO ai_topic_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT topic_id, 3, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_topic_embeddings_3_1; - - INSERT INTO ai_topic_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT topic_id, 4, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_topic_embeddings_4_1; - - INSERT INTO ai_topic_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT topic_id, 5, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_topic_embeddings_5_1; - - INSERT INTO ai_topic_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT topic_id, 6, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_topic_embeddings_6_1; - - INSERT INTO ai_topic_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT topic_id, 7, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_topic_embeddings_7_1; - - INSERT INTO ai_topic_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT topic_id, 8, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_topic_embeddings_8_1; - - INSERT INTO ai_post_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT post_id, 1, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_post_embeddings_1_1; - - INSERT INTO ai_post_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT post_id, 2, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_post_embeddings_2_1; - - INSERT INTO ai_post_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT post_id, 3, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_post_embeddings_3_1; - - INSERT INTO ai_post_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT post_id, 4, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_post_embeddings_4_1; - - INSERT INTO ai_post_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT post_id, 5, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_post_embeddings_5_1; - - INSERT INTO ai_post_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT post_id, 6, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_post_embeddings_6_1; - - INSERT INTO ai_post_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT post_id, 7, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_post_embeddings_7_1; - - INSERT INTO ai_post_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT post_id, 8, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_post_embeddings_8_1; - - INSERT INTO ai_document_fragment_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT rag_document_fragment_id, 1, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_document_fragment_embeddings_1_1; - - INSERT INTO ai_document_fragment_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT rag_document_fragment_id, 2, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_document_fragment_embeddings_2_1; - - INSERT INTO ai_document_fragment_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT rag_document_fragment_id, 3, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_document_fragment_embeddings_3_1; - - INSERT INTO ai_document_fragment_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT rag_document_fragment_id, 4, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_document_fragment_embeddings_4_1; - - INSERT INTO ai_document_fragment_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT rag_document_fragment_id, 5, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_document_fragment_embeddings_5_1; - - INSERT INTO ai_document_fragment_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT rag_document_fragment_id, 6, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_document_fragment_embeddings_6_1; - - INSERT INTO ai_document_fragment_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT rag_document_fragment_id, 7, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_document_fragment_embeddings_7_1; - - INSERT INTO ai_document_fragment_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT rag_document_fragment_id, 8, model_version, 1, strategy_version, digest, embeddings, created_at, updated_at - FROM ai_document_fragment_embeddings_8_1; - SQL - - begin - DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation - rescue StandardError => e - Rails.logger.error("Failed to index embeddings: #{e}") - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240611170906_drop_old_embeddings_tables.rb b/db/migrate/20240611170906_drop_old_embeddings_tables.rb deleted file mode 100644 index 327f158a..00000000 --- a/db/migrate/20240611170906_drop_old_embeddings_tables.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class DropOldEmbeddingsTables < ActiveRecord::Migration[7.0] - def up - drop_table :ai_topic_embeddings_1_1 - drop_table :ai_topic_embeddings_2_1 - drop_table :ai_topic_embeddings_3_1 - drop_table :ai_topic_embeddings_4_1 - drop_table :ai_topic_embeddings_5_1 - drop_table :ai_topic_embeddings_6_1 - drop_table :ai_topic_embeddings_7_1 - drop_table :ai_topic_embeddings_8_1 - drop_table :ai_post_embeddings_1_1 - drop_table :ai_post_embeddings_2_1 - drop_table :ai_post_embeddings_3_1 - drop_table :ai_post_embeddings_4_1 - drop_table :ai_post_embeddings_5_1 - drop_table :ai_post_embeddings_6_1 - drop_table :ai_post_embeddings_7_1 - drop_table :ai_post_embeddings_8_1 - drop_table :ai_document_fragment_embeddings_1_1 - drop_table :ai_document_fragment_embeddings_2_1 - drop_table :ai_document_fragment_embeddings_3_1 - drop_table :ai_document_fragment_embeddings_4_1 - drop_table :ai_document_fragment_embeddings_5_1 - drop_table :ai_document_fragment_embeddings_6_1 - drop_table :ai_document_fragment_embeddings_7_1 - drop_table :ai_document_fragment_embeddings_8_1 - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240618080148_create_ai_tools.rb b/db/migrate/20240618080148_create_ai_tools.rb deleted file mode 100644 index 30d84def..00000000 --- a/db/migrate/20240618080148_create_ai_tools.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class CreateAiTools < ActiveRecord::Migration[7.0] - def change - create_table :ai_tools do |t| - t.string :name, null: false, max_length: 100, unique: true - t.string :description, null: false, max_length: 1000 - - t.string :summary, null: false, max_length: 255 - - t.jsonb :parameters, null: false, default: {} - t.text :script, null: false, max_length: 100_000 - t.integer :created_by_id, null: false - - t.boolean :enabled, null: false, default: true - t.timestamps - end - end -end diff --git a/db/migrate/20240619193057_choose_llm_model_setting_migration.rb b/db/migrate/20240619193057_choose_llm_model_setting_migration.rb deleted file mode 100644 index 30015207..00000000 --- a/db/migrate/20240619193057_choose_llm_model_setting_migration.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -class ChooseLlmModelSettingMigration < ActiveRecord::Migration[7.0] - def up - transition_to_llm_model("ai_helper_model") - transition_to_llm_model("ai_embeddings_semantic_search_hyde_model") - end - - def transition_to_llm_model(llm_setting_name) - setting_value = - DB - .query_single( - "SELECT value FROM site_settings WHERE name = :llm_setting", - llm_setting: llm_setting_name, - ) - .first - .to_s - - return if setting_value.empty? - - provider_and_model = setting_value.split(":") - provider = provider_and_model.first - model = provider_and_model.second - return if provider == "custom" - - llm_model_id = DB.query_single(<<~SQL, provider: provider, model: model).first.to_s - SELECT id FROM llm_models WHERE provider = :provider AND name = :model - SQL - - return if llm_model_id.empty? - - DB.exec(<<~SQL, llm_setting: llm_setting_name, new_value: "custom:#{llm_model_id}") - UPDATE site_settings SET value=:new_value WHERE name=:llm_setting - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240619211337_update_automation_script_models.rb b/db/migrate/20240619211337_update_automation_script_models.rb deleted file mode 100644 index 53fe59be..00000000 --- a/db/migrate/20240619211337_update_automation_script_models.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -class UpdateAutomationScriptModels < ActiveRecord::Migration[7.0] - def up - script_names = %w[llm_triage llm_report] - - fields_to_update = DB.query(<<~SQL, script_names: script_names) - SELECT fields.id, fields.metadata - FROM discourse_automation_fields fields - INNER JOIN discourse_automation_automations automations ON automations.id = fields.automation_id - WHERE fields.name = 'model' - AND automations.script IN (:script_names) - SQL - - return if fields_to_update.empty? - - updated_fields = - fields_to_update - .map do |field| - new_metadata = { "value" => translate_model(field.metadata["value"]) }.to_json - - "(#{field.id}, '#{new_metadata}')" if new_metadata.present? - end - .compact - - return if updated_fields.empty? - - DB.exec(<<~SQL) - UPDATE discourse_automation_fields AS fields - SET metadata = new_fields.metadata::jsonb - FROM (VALUES #{updated_fields.join(", ")}) AS new_fields(id, metadata) - WHERE new_fields.id::bigint = fields.id - SQL - end - - def translate_model(current_model) - options = DB.query(<<~SQL, name: current_model.to_s).to_a - SELECT id, provider - FROM llm_models - WHERE name = :name - SQL - - return if options.empty? - return "custom:#{options.first.id}" if options.length == 1 - - priority_provider = options.find { |o| o.provider == "aws_bedrock" || o.provider == "vllm" } - - return "custom:#{priority_provider.id}" if priority_provider - - "custom:#{options.first.id}" - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240624135356_llm_model_custom_params.rb b/db/migrate/20240624135356_llm_model_custom_params.rb deleted file mode 100644 index ce47b2c2..00000000 --- a/db/migrate/20240624135356_llm_model_custom_params.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class LlmModelCustomParams < ActiveRecord::Migration[7.0] - def change - add_column :llm_models, :provider_params, :jsonb - end -end diff --git a/db/migrate/20240624202602_add_provider_specific_params_to_llm_models.rb b/db/migrate/20240624202602_add_provider_specific_params_to_llm_models.rb deleted file mode 100644 index 10416ee1..00000000 --- a/db/migrate/20240624202602_add_provider_specific_params_to_llm_models.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true -class AddProviderSpecificParamsToLlmModels < ActiveRecord::Migration[7.0] - def up - open_ai_organization = fetch_setting("ai_openai_organization") - - DB.exec(<<~SQL, organization: open_ai_organization) if open_ai_organization - UPDATE llm_models - SET provider_params = jsonb_build_object('organization', :organization) - WHERE provider = 'open_ai' AND provider_params IS NULL - SQL - - bedrock_region = fetch_setting("ai_bedrock_region") || "us-east-1" - bedrock_access_key_id = fetch_setting("ai_bedrock_access_key_id") - - DB.exec(<<~SQL, key_id: bedrock_access_key_id, region: bedrock_region) if bedrock_access_key_id - UPDATE llm_models - SET - provider_params = jsonb_build_object('access_key_id', :key_id, 'region', :region), - name = CASE name WHEN 'claude-2' THEN 'anthropic.claude-v2:1' - WHEN 'claude-3-haiku' THEN 'anthropic.claude-3-haiku-20240307-v1:0' - WHEN 'claude-3-sonnet' THEN 'anthropic.claude-3-sonnet-20240229-v1:0' - WHEN 'claude-instant-1' THEN 'anthropic.claude-instant-v1' - WHEN 'claude-3-opus' THEN 'anthropic.claude-3-opus-20240229-v1:0' - WHEN 'claude-3-5-sonnet' THEN 'anthropic.claude-3-5-sonnet-20240620-v1:0' - ELSE name - END - WHERE provider = 'aws_bedrock' AND provider_params IS NULL - SQL - end - - def fetch_setting(name) - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: name, - ).first - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240703135444_llm_models_for_summarization.rb b/db/migrate/20240703135444_llm_models_for_summarization.rb deleted file mode 100644 index 5046139e..00000000 --- a/db/migrate/20240703135444_llm_models_for_summarization.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -class LlmModelsForSummarization < ActiveRecord::Migration[7.0] - def up - setting_value = - DB - .query_single( - "SELECT value FROM site_settings WHERE name = :llm_setting", - llm_setting: "ai_summarization_strategy", - ) - .first - .to_s - - return if setting_value.empty? - - gpt_models = %w[gpt-4 gpt-4-32k gpt-4-turbo gpt-4o gpt-3.5-turbo gpt-3.5-turbo-16k] - gemini_models = %w[gemini-pro gemini-1.5-pro gemini-1.5-flash] - claude_models = %w[claude-2 claude-instant-1 claude-3-haiku claude-3-sonnet claude-3-opus] - oss_models = %w[mistralai/Mixtral-8x7B-Instruct-v0.1 mistralai/Mixtral-8x7B-Instruct-v0.1] - - providers = [] - prov_priority = "" - - if gpt_models.include?(setting_value) - providers = %w[azure open_ai] - prov_priority = "azure" - elsif gemini_models.include?(setting_value) - providers = %w[google] - prov_priority = "google" - elsif claude_models.include?(setting_value) - providers = %w[aws_bedrock anthropic] - prov_priority = "aws_bedrock" - elsif oss_models.include?(setting_value) - providers = %w[hugging_face vllm] - prov_priority = "vllm" - end - - insert_llm_model(setting_value, providers, prov_priority) if providers.present? - end - - def insert_llm_model(old_value, providers, priority) - matching_models = DB.query(<<~SQL, model_name: old_value, providers: providers) - SELECT * FROM llm_models WHERE name = :model_name AND provider IN (:providers) - SQL - - return if matching_models.empty? - - priority_model = matching_models.find { |m| m.provider == priority } || matching_models.first - new_value = "custom:#{priority_model.id}" - - DB.exec(<<~SQL, new_value: new_value) - INSERT INTO site_settings(name, data_type, value, created_at, updated_at) - VALUES ('ai_summarization_model', 1, :new_value, NOW(), NOW()) - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240704020102_reset_identity_on_ai_summary.rb b/db/migrate/20240704020102_reset_identity_on_ai_summary.rb deleted file mode 100644 index 5c87763d..00000000 --- a/db/migrate/20240704020102_reset_identity_on_ai_summary.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true -class ResetIdentityOnAiSummary < ActiveRecord::Migration[7.0] - def up - add_index :ai_summaries, %i[target_type target_id] - - # we need to reset identity since we moved this from the old summary_sections table - execute <<-SQL - DO $$ - DECLARE - max_id integer; - BEGIN - SELECT MAX(id) INTO max_id FROM ai_summaries; - IF max_id IS NOT NULL THEN - PERFORM setval(pg_get_serial_sequence('ai_summaries', 'id'), max_id); - END IF; - END $$ - SQL - end - - def down - remove_index :ai_summaries, %i[target_type target_id] - end -end diff --git a/db/migrate/20240708193243_fix_vllm_model_name.rb b/db/migrate/20240708193243_fix_vllm_model_name.rb deleted file mode 100644 index b5d715ce..00000000 --- a/db/migrate/20240708193243_fix_vllm_model_name.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true -class FixVllmModelName < ActiveRecord::Migration[7.1] - def up - vllm_mixtral_model_id = DB.query_single(<<~SQL).first - SELECT id FROM llm_models WHERE name = 'mistralai/Mixtral' - SQL - - DB.exec(<<~SQL, target_id: vllm_mixtral_model_id) if vllm_mixtral_model_id - UPDATE llm_models - SET name = 'mistralai/Mixtral-8x7B-Instruct-v0.1' - WHERE id = :target_id - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240719143453_llm_model_vision_enabled.rb b/db/migrate/20240719143453_llm_model_vision_enabled.rb deleted file mode 100644 index c2280818..00000000 --- a/db/migrate/20240719143453_llm_model_vision_enabled.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class LlmModelVisionEnabled < ActiveRecord::Migration[7.1] - def change - add_column :llm_models, :vision_enabled, :boolean, default: false, null: false - end -end diff --git a/db/migrate/20240726164937_fix_ai_summaries_sequence.rb b/db/migrate/20240726164937_fix_ai_summaries_sequence.rb deleted file mode 100644 index 918afc57..00000000 --- a/db/migrate/20240726164937_fix_ai_summaries_sequence.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -class FixAiSummariesSequence < ActiveRecord::Migration[7.0] - def up - begin - execute <<-SQL - SELECT - SETVAL ( - 'ai_summaries_id_seq', - ( - SELECT - GREATEST ( - ( - SELECT - MAX(id) - FROM - summary_sections - ), - ( - SELECT - MAX(id) - FROM - ai_summaries - ) - ) - ), - true - ); - SQL - rescue ActiveRecord::StatementInvalid => e - # if the summary_table does not exist, we can ignore the error - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20240807150605_add_default_to_provider_params.rb b/db/migrate/20240807150605_add_default_to_provider_params.rb deleted file mode 100644 index 4363d0bb..00000000 --- a/db/migrate/20240807150605_add_default_to_provider_params.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class AddDefaultToProviderParams < ActiveRecord::Migration[7.1] - def change - change_column_default :llm_models, :provider_params, from: nil, to: {} - end -end diff --git a/db/migrate/20240909180908_add_ai_summary_type_column.rb b/db/migrate/20240909180908_add_ai_summary_type_column.rb deleted file mode 100644 index 82284106..00000000 --- a/db/migrate/20240909180908_add_ai_summary_type_column.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class AddAiSummaryTypeColumn < ActiveRecord::Migration[7.1] - def change - add_column :ai_summaries, :summary_type, :integer, default: 0, null: false - end -end diff --git a/db/migrate/20240912052713_add_target_to_rag_document_fragment.rb b/db/migrate/20240912052713_add_target_to_rag_document_fragment.rb deleted file mode 100644 index 43b48ad4..00000000 --- a/db/migrate/20240912052713_add_target_to_rag_document_fragment.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AddTargetToRagDocumentFragment < ActiveRecord::Migration[7.1] - def change - add_column :rag_document_fragments, :target_id, :integer, null: true - add_column :rag_document_fragments, :target_type, :string, limit: 800, null: true - add_index :rag_document_fragments, %i[target_type target_id] - end -end diff --git a/db/migrate/20240913054440_add_rag_columns_to_ai_tools.rb b/db/migrate/20240913054440_add_rag_columns_to_ai_tools.rb deleted file mode 100644 index 4ec6100b..00000000 --- a/db/migrate/20240913054440_add_rag_columns_to_ai_tools.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class AddRagColumnsToAiTools < ActiveRecord::Migration[7.1] - def change - add_column :ai_tools, :rag_chunk_tokens, :integer, null: false, default: 374 - add_column :ai_tools, :rag_chunk_overlap_tokens, :integer, null: false, default: 10 - end -end diff --git a/db/migrate/20241008054440_create_binary_indexes_for_embeddings.rb b/db/migrate/20241008054440_create_binary_indexes_for_embeddings.rb deleted file mode 100644 index aa31a4e3..00000000 --- a/db/migrate/20241008054440_create_binary_indexes_for_embeddings.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class CreateBinaryIndexesForEmbeddings < ActiveRecord::Migration[7.1] - def up - %w[topic post document_fragment].each do |type| - # our supported embeddings models IDs and dimensions - [ - [1, 768], - [2, 1536], - [3, 1024], - [4, 1024], - [5, 768], - [6, 1536], - [7, 2000], - [8, 1024], - ].each { |model_id, dimensions| execute <<-SQL } - CREATE INDEX ai_#{type}_embeddings_#{model_id}_1_search_bit ON ai_#{type}_embeddings - USING hnsw ((binary_quantize(embeddings)::bit(#{dimensions})) bit_hamming_ops) - WHERE model_id = #{model_id} AND strategy_id = 1; - SQL - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20241009230724_add_forced_tool_count_to_ai_personas.rb b/db/migrate/20241009230724_add_forced_tool_count_to_ai_personas.rb deleted file mode 100644 index 23a0496d..00000000 --- a/db/migrate/20241009230724_add_forced_tool_count_to_ai_personas.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddForcedToolCountToAiPersonas < ActiveRecord::Migration[7.1] - def change - add_column :ai_personas, :forced_tool_count, :integer, default: -1, null: false - end -end diff --git a/db/migrate/20241014010245_ai_persona_chat_topic_refactor.rb b/db/migrate/20241014010245_ai_persona_chat_topic_refactor.rb deleted file mode 100644 index 9a7e9a88..00000000 --- a/db/migrate/20241014010245_ai_persona_chat_topic_refactor.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class AiPersonaChatTopicRefactor < ActiveRecord::Migration[7.1] - def change - add_column :ai_personas, :allow_chat_channel_mentions, :boolean, default: false, null: false - add_column :ai_personas, :allow_chat_direct_messages, :boolean, default: false, null: false - add_column :ai_personas, :allow_topic_mentions, :boolean, default: false, null: false - add_column :ai_personas, :allow_personal_messages, :boolean, default: true, null: false - add_column :ai_personas, :force_default_llm, :boolean, default: false, null: false - - execute <<~SQL - UPDATE ai_personas - SET allow_chat_channel_mentions = mentionable, allow_chat_direct_messages = true - WHERE allow_chat = true - SQL - - execute <<~SQL - UPDATE ai_personas - SET allow_topic_mentions = true - WHERE mentionable = true - SQL - end -end diff --git a/db/migrate/20241020010245_add_tool_name_to_ai_tools.rb b/db/migrate/20241020010245_add_tool_name_to_ai_tools.rb deleted file mode 100644 index ab9a549f..00000000 --- a/db/migrate/20241020010245_add_tool_name_to_ai_tools.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class AddToolNameToAiTools < ActiveRecord::Migration[7.1] - def up - add_column :ai_tools, - :tool_name, - :string, - null: false, - limit: 100, - default: "", - if_not_exists: true - - # Migrate existing name to tool_name - execute <<~SQL - UPDATE ai_tools - SET tool_name = regexp_replace(LOWER(name),'[^a-z0-9_]','', 'g'); - SQL - end - - def down - remove_column :ai_tools, :tool_name, if_exists: true - end -end diff --git a/db/migrate/20241023033955_add_feature_context_to_ai_api_log.rb b/db/migrate/20241023033955_add_feature_context_to_ai_api_log.rb deleted file mode 100644 index 5191541c..00000000 --- a/db/migrate/20241023033955_add_feature_context_to_ai_api_log.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true -# -class AddFeatureContextToAiApiLog < ActiveRecord::Migration[7.1] - def change - add_column :ai_api_audit_logs, :feature_context, :jsonb - end -end diff --git a/db/migrate/20241023041242_add_unique_constraint_to_ai_tools.rb b/db/migrate/20241023041242_add_unique_constraint_to_ai_tools.rb deleted file mode 100644 index 2c1a37a0..00000000 --- a/db/migrate/20241023041242_add_unique_constraint_to_ai_tools.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true -class AddUniqueConstraintToAiTools < ActiveRecord::Migration[7.1] - def up - # We need to remove duplicates before adding the unique constraint - execute <<~SQL - WITH duplicates AS ( - SELECT name, COUNT(*) as count, MIN(id) as keeper_id - FROM ai_tools - GROUP BY name - HAVING COUNT(*) > 1 - ) - UPDATE ai_tools AS p - SET name = CONCAT(p.name, p.id) - FROM duplicates d - WHERE p.name = d.name - AND p.id != d.keeper_id; - SQL - - add_index :ai_personas, :name, unique: true, if_not_exists: true - end - - def down - remove_index :ai_personas, :name, if_exists: true - end -end diff --git a/db/migrate/20241025135522_alter_ai_ids_to_bigint.rb b/db/migrate/20241025135522_alter_ai_ids_to_bigint.rb deleted file mode 100644 index b94d0b4d..00000000 --- a/db/migrate/20241025135522_alter_ai_ids_to_bigint.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class AlterAiIdsToBigint < ActiveRecord::Migration[7.1] - def up - change_column :ai_document_fragment_embeddings, :rag_document_fragment_id, :bigint - change_column :classification_results, :target_id, :bigint - change_column :rag_document_fragments, :target_id, :bigint - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20241028034232_add_unique_ai_stream_conversation_user_id_index.rb b/db/migrate/20241028034232_add_unique_ai_stream_conversation_user_id_index.rb deleted file mode 100644 index cff38477..00000000 --- a/db/migrate/20241028034232_add_unique_ai_stream_conversation_user_id_index.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true -class AddUniqueAiStreamConversationUserIdIndex < ActiveRecord::Migration[7.1] - def change - add_index :user_custom_fields, - [:value], - unique: true, - where: "name = 'ai-stream-conversation-unique-id'" - end -end diff --git a/db/migrate/20241031145203_track_ai_summary_origin.rb b/db/migrate/20241031145203_track_ai_summary_origin.rb deleted file mode 100644 index 25b571c2..00000000 --- a/db/migrate/20241031145203_track_ai_summary_origin.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class TrackAiSummaryOrigin < ActiveRecord::Migration[7.1] - def change - add_column :ai_summaries, :origin, :integer - end -end diff --git a/db/migrate/20241031180044_set_origin_for_existing_ai_summaries.rb b/db/migrate/20241031180044_set_origin_for_existing_ai_summaries.rb deleted file mode 100644 index 82c67f92..00000000 --- a/db/migrate/20241031180044_set_origin_for_existing_ai_summaries.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true -class SetOriginForExistingAiSummaries < ActiveRecord::Migration[7.1] - def up - DB.exec <<~SQL - UPDATE ai_summaries - SET origin = CASE WHEN summary_type = 0 THEN 0 ELSE 1 END - WHERE origin IS NULL - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20241104053017_add_ai_artifacts.rb b/db/migrate/20241104053017_add_ai_artifacts.rb deleted file mode 100644 index 3ca78927..00000000 --- a/db/migrate/20241104053017_add_ai_artifacts.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -class AddAiArtifacts < ActiveRecord::Migration[7.1] - def change - create_table :ai_artifacts do |t| - t.integer :user_id, null: false - t.integer :post_id, null: false - t.string :name, null: false, limit: 255 - t.string :html, limit: 65_535 # ~64KB limit - t.string :css, limit: 65_535 # ~64KB limit - t.string :js, limit: 65_535 # ~64KB limit - t.jsonb :metadata # For any additional properties - - t.timestamps - end - end -end diff --git a/db/migrate/20241125132452_unique_ai_summaries.rb b/db/migrate/20241125132452_unique_ai_summaries.rb deleted file mode 100644 index 08681201..00000000 --- a/db/migrate/20241125132452_unique_ai_summaries.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -class UniqueAiSummaries < ActiveRecord::Migration[7.1] - def up - execute <<~SQL - DELETE FROM ai_summaries ais1 - USING ai_summaries ais2 - WHERE ais1.id < ais2.id - AND ais1.target_id = ais2.target_id - AND ais1.target_type = ais2.target_type - AND ais1.summary_type = ais2.summary_type - SQL - - add_index :ai_summaries, %i[target_id target_type summary_type], unique: true - end - - def down - remove_index :ai_summaries, column: %i[target_id target_type summary_type] - end -end diff --git a/db/migrate/20241126033812_rename_ai_gist_batch_setting.rb b/db/migrate/20241126033812_rename_ai_gist_batch_setting.rb deleted file mode 100644 index 196887e5..00000000 --- a/db/migrate/20241126033812_rename_ai_gist_batch_setting.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class RenameAiGistBatchSetting < ActiveRecord::Migration[7.0] - def up - execute "UPDATE site_settings SET name = 'ai_summary_gists_allowed_groups' WHERE name = 'ai_hot_topic_gists_allowed_groups'" - end - - def down - execute "UPDATE site_settings SET name = 'ai_hot_topic_gists_allowed_groups' WHERE name = 'ai_summary_gists_allowed_groups'" - end -end diff --git a/db/migrate/20241128010221_add_cached_tokens_to_ai_api_audit_log.rb b/db/migrate/20241128010221_add_cached_tokens_to_ai_api_audit_log.rb deleted file mode 100644 index a100bb84..00000000 --- a/db/migrate/20241128010221_add_cached_tokens_to_ai_api_audit_log.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AddCachedTokensToAiApiAuditLog < ActiveRecord::Migration[7.2] - def change - add_column :ai_api_audit_logs, :cached_tokens, :integer - add_index :ai_api_audit_logs, %i[created_at feature_name] - add_index :ai_api_audit_logs, %i[created_at language_model] - end -end diff --git a/db/migrate/20241129190708_fix_classification_data.rb b/db/migrate/20241129190708_fix_classification_data.rb deleted file mode 100644 index 5e91b6e2..00000000 --- a/db/migrate/20241129190708_fix_classification_data.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -class FixClassificationData < ActiveRecord::Migration[7.2] - def up - classifications = DB.query(<<~SQL) - SELECT id, classification - FROM classification_results - WHERE classification_type = 'sentiment' - AND SUBSTRING(LTRIM(classification::text), 1, 1) = '[' - SQL - - transformed = - classifications.reduce([]) do |memo, c| - hash_result = {} - c.classification.each { |r| hash_result[r["label"]] = r["score"] } - - memo << { id: c.id, fixed_classification: hash_result } - end - - transformed_json = transformed.to_json - - DB.exec(<<~SQL, values: transformed_json) - UPDATE classification_results - SET classification = N.fixed_classification - FROM ( - SELECT (value::jsonb->'id')::integer AS id, (value::jsonb->'fixed_classification')::jsonb AS fixed_classification - FROM jsonb_array_elements(:values::jsonb) - ) N - WHERE classification_results.id = N.id - AND classification_type = 'sentiment' - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20241130003808_add_artifact_versions.rb b/db/migrate/20241130003808_add_artifact_versions.rb deleted file mode 100644 index bdab7155..00000000 --- a/db/migrate/20241130003808_add_artifact_versions.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true -class AddArtifactVersions < ActiveRecord::Migration[7.0] - def change - create_table :ai_artifact_versions do |t| - t.bigint :ai_artifact_id, null: false - t.integer :version_number, null: false - t.string :html, limit: 65_535 - t.string :css, limit: 65_535 - t.string :js, limit: 65_535 - t.jsonb :metadata - t.string :change_description - t.timestamps - - t.index %i[ai_artifact_id version_number], unique: true - end - end -end diff --git a/db/migrate/20241206030229_add_ai_moderation_settings.rb b/db/migrate/20241206030229_add_ai_moderation_settings.rb deleted file mode 100644 index 12e17782..00000000 --- a/db/migrate/20241206030229_add_ai_moderation_settings.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true -class AddAiModerationSettings < ActiveRecord::Migration[7.2] - def change - create_enum :ai_moderation_setting_type, %w[spam nsfw custom] - - create_table :ai_moderation_settings do |t| - t.enum :setting_type, enum_type: "ai_moderation_setting_type", null: false - t.jsonb :data, default: {} - t.bigint :llm_model_id, null: false - t.timestamps - end - - add_index :ai_moderation_settings, :setting_type, unique: true - end -end diff --git a/db/migrate/20241206051225_add_ai_spam_logs.rb b/db/migrate/20241206051225_add_ai_spam_logs.rb deleted file mode 100644 index 5ef42388..00000000 --- a/db/migrate/20241206051225_add_ai_spam_logs.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -class AddAiSpamLogs < ActiveRecord::Migration[7.2] - def change - create_table :ai_spam_logs do |t| - t.bigint :post_id, null: false - t.bigint :llm_model_id, null: false - t.bigint :ai_api_audit_log_id - t.bigint :reviewable_id - t.boolean :is_spam, null: false - t.string :payload, null: false, default: "", limit: 20_000 - t.timestamps - end - - add_index :ai_spam_logs, :post_id - end -end diff --git a/db/migrate/20241217164540_create_embedding_definitions.rb b/db/migrate/20241217164540_create_embedding_definitions.rb deleted file mode 100644 index 517ef407..00000000 --- a/db/migrate/20241217164540_create_embedding_definitions.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -class CreateEmbeddingDefinitions < ActiveRecord::Migration[7.2] - def change - create_table :embedding_definitions do |t| - t.string :display_name, null: false - t.integer :dimensions, null: false - t.integer :max_sequence_length, null: false - t.integer :version, null: false, default: 1 - t.string :pg_function, null: false - t.string :provider, null: false - t.string :tokenizer_class, null: false - t.string :url, null: false - t.string :api_key - t.boolean :seeded, null: false, default: false - t.jsonb :provider_params - t.timestamps - end - end -end diff --git a/db/migrate/20241230153300_new_embeddings_tables.rb b/db/migrate/20241230153300_new_embeddings_tables.rb deleted file mode 100644 index dacc148f..00000000 --- a/db/migrate/20241230153300_new_embeddings_tables.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -class NewEmbeddingsTables < ActiveRecord::Migration[7.2] - def up - create_table :ai_topics_embeddings, id: false do |t| - t.bigint :topic_id, null: false - t.bigint :model_id, null: false - t.integer :model_version, null: false - t.integer :strategy_id, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "halfvec", null: false - t.timestamps - - t.index %i[model_id strategy_id topic_id], - unique: true, - name: "index_ai_topics_embeddings_on_model_strategy_topic" - end - - create_table :ai_posts_embeddings, id: false do |t| - t.bigint :post_id, null: false - t.bigint :model_id, null: false - t.integer :model_version, null: false - t.integer :strategy_id, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "halfvec", null: false - t.timestamps - - t.index %i[model_id strategy_id post_id], - unique: true, - name: "index_ai_posts_embeddings_on_model_strategy_post" - end - - create_table :ai_document_fragments_embeddings, id: false do |t| - t.bigint :rag_document_fragment_id, null: false - t.bigint :model_id, null: false - t.integer :model_version, null: false - t.integer :strategy_id, null: false - t.integer :strategy_version, null: false - t.text :digest, null: false - t.column :embeddings, "halfvec", null: false - t.timestamps - - t.index %i[model_id strategy_id rag_document_fragment_id], - unique: true, - name: "index_ai_fragments_embeddings_on_model_strategy_fragment" - end - - # Copied from 20241008054440_create_binary_indexes_for_embeddings - %w[topics posts document_fragments].each do |type| - # our supported embeddings models IDs and dimensions - [ - [1, 768], - [2, 1536], - [3, 1024], - [4, 1024], - [5, 768], - [6, 1536], - [7, 2000], - [8, 1024], - ].each { |model_id, dimensions| execute <<-SQL } - CREATE INDEX ai_#{type}_embeddings_#{model_id}_1_search_bit ON ai_#{type}_embeddings - USING hnsw ((binary_quantize(embeddings)::bit(#{dimensions})) bit_hamming_ops) - WHERE model_id = #{model_id} AND strategy_id = 1; - SQL - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250102035341_add_llm_quota_tables.rb b/db/migrate/20250102035341_add_llm_quota_tables.rb deleted file mode 100644 index 87a4f49c..00000000 --- a/db/migrate/20250102035341_add_llm_quota_tables.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class AddLlmQuotaTables < ActiveRecord::Migration[7.2] - def change - create_table :llm_quotas do |t| - t.bigint :group_id, null: false - t.bigint :llm_model_id, null: false - t.integer :max_tokens - t.integer :max_usages - t.integer :duration_seconds, null: false - t.timestamps - end - - add_index :llm_quotas, :llm_model_id - add_index :llm_quotas, %i[group_id llm_model_id], unique: true - - create_table :llm_quota_usages do |t| - t.bigint :user_id, null: false - t.bigint :llm_quota_id, null: false - t.integer :input_tokens_used, null: false - t.integer :output_tokens_used, null: false - t.integer :usages, null: false - t.datetime :started_at, null: false - t.datetime :reset_at, null: false - t.timestamps - end - - add_index :llm_quota_usages, :llm_quota_id - add_index :llm_quota_usages, %i[user_id llm_quota_id], unique: true - end -end diff --git a/db/migrate/20250110114305_embedding_config_data_migration.rb b/db/migrate/20250110114305_embedding_config_data_migration.rb deleted file mode 100644 index acab9a57..00000000 --- a/db/migrate/20250110114305_embedding_config_data_migration.rb +++ /dev/null @@ -1,204 +0,0 @@ -# frozen_string_literal: true - -class EmbeddingConfigDataMigration < ActiveRecord::Migration[7.0] - def up - current_model = fetch_setting("ai_embeddings_model") || "bge-large-en" - provider = provider_for(current_model) - - if provider.present? - attrs = creds_for(provider) - - if attrs.present? - attrs = attrs.merge(model_attrs(current_model)) - attrs[:display_name] = current_model - attrs[:provider] = provider - persist_config(attrs) - end - end - end - - def down - end - - # Utils - - def fetch_setting(name) - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: name, - ).first || ENV["DISCOURSE_#{name&.upcase}"] - end - - def provider_for(model) - cloudflare_api_token = fetch_setting("ai_cloudflare_workers_api_token") - - return "cloudflare" if model == "bge-large-en" && cloudflare_api_token.present? - - tei_models = %w[bge-large-en bge-m3 multilingual-e5-large] - return "hugging_face" if tei_models.include?(model) - - return "google" if model == "gemini" - - if %w[text-embedding-3-large text-embedding-3-small text-embedding-ada-002].include?(model) - return "open_ai" - end - - nil - end - - def creds_for(provider) - # CF - if provider == "cloudflare" - api_key = fetch_setting("ai_cloudflare_workers_api_token") - account_id = fetch_setting("ai_cloudflare_workers_account_id") - - return if api_key.blank? || account_id.blank? - - { - url: - "https://api.cloudflare.com/client/v4/accounts/#{account_id}/ai/run/@cf/baai/bge-large-en-v1.5", - api_key: api_key, - } - # TEI - elsif provider == "hugging_face" - seeded = false - endpoint = fetch_setting("ai_hugging_face_tei_endpoint") - - if endpoint.blank? - endpoint = fetch_setting("ai_hugging_face_tei_endpoint_srv") - if endpoint.present? - endpoint = "srv://#{endpoint}" - seeded = true - end - end - - api_key = fetch_setting("ai_hugging_face_tei_api_key") - - return if endpoint.blank? || api_key.blank? - - { url: endpoint, api_key: api_key, seeded: seeded } - # Gemini - elsif provider == "google" - api_key = fetch_setting("ai_gemini_api_key") - - return if api_key.blank? - - { - url: "https://generativelanguage.googleapis.com/v1beta/models/embedding-001:embedContent", - api_key: api_key, - } - - # Open AI - elsif provider == "open_ai" - endpoint = fetch_setting("ai_openai_embeddings_url") || "https://api.openai.com/v1/embeddings" - api_key = fetch_setting("ai_openai_api_key") - - return if endpoint.blank? || api_key.blank? - - { url: endpoint, api_key: api_key } - else - nil - end - end - - def model_attrs(model_name) - if model_name == "bge-large-en" - { - dimensions: 1024, - max_sequence_length: 512, - id: 4, - pg_function: "<#>", - tokenizer_class: "DiscourseAi::Tokenizer::BgeLargeEnTokenizer", - } - elsif model_name == "bge-m3" - { - dimensions: 1024, - max_sequence_length: 8192, - id: 8, - pg_function: "<#>", - tokenizer_class: "DiscourseAi::Tokenizer::BgeM3Tokenizer", - } - elsif model_name == "gemini" - { - dimensions: 768, - max_sequence_length: 1536, - id: 5, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiTokenizer", - } - elsif model_name == "multilingual-e5-large" - { - dimensions: 1024, - max_sequence_length: 512, - id: 3, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::MultilingualE5LargeTokenizer", - } - elsif model_name == "text-embedding-3-large" - { - dimensions: 2000, - max_sequence_length: 8191, - id: 7, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiTokenizer", - provider_params: { - model_name: "text-embedding-3-large", - }, - } - elsif model_name == "text-embedding-3-small" - { - dimensions: 1536, - max_sequence_length: 8191, - id: 6, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiTokenizer", - provider_params: { - model_name: "text-embedding-3-small", - }, - } - else - { - dimensions: 1536, - max_sequence_length: 8191, - id: 2, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiTokenizer", - provider_params: { - model_name: "text-embedding-ada-002", - }, - } - end - end - - def persist_config(attrs) - DB.exec( - <<~SQL, - INSERT INTO embedding_definitions (id, display_name, dimensions, max_sequence_length, version, pg_function, provider, tokenizer_class, url, api_key, provider_params, seeded, created_at, updated_at) - VALUES (:id, :display_name, :dimensions, :max_sequence_length, 1, :pg_function, :provider, :tokenizer_class, :url, :api_key, :provider_params, :seeded, :now, :now) - SQL - id: attrs[:id], - display_name: attrs[:display_name], - dimensions: attrs[:dimensions], - max_sequence_length: attrs[:max_sequence_length], - pg_function: attrs[:pg_function], - provider: attrs[:provider], - tokenizer_class: attrs[:tokenizer_class], - url: attrs[:url], - api_key: attrs[:api_key], - provider_params: attrs[:provider_params]&.to_json, - seeded: !!attrs[:seeded], - now: Time.zone.now, - ) - - # We hardcoded the ID to match with already generated embeddings. Let's restart the seq to avoid conflicts. - DB.exec( - "ALTER SEQUENCE embedding_definitions_id_seq RESTART WITH :new_seq", - new_seq: attrs[:id].to_i + 1, - ) - - DB.exec(<<~SQL, new_value: attrs[:id]) - INSERT INTO site_settings(name, data_type, value, created_at, updated_at) - VALUES ('ai_embeddings_selected_model', 3, :new_value, NOW(), NOW()) - SQL - end -end diff --git a/db/migrate/20250114160417_backfill_topic_embeddings.rb b/db/migrate/20250114160417_backfill_topic_embeddings.rb deleted file mode 100644 index 3ec94de6..00000000 --- a/db/migrate/20250114160417_backfill_topic_embeddings.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true -class BackfillTopicEmbeddings < ActiveRecord::Migration[7.2] - disable_ddl_transaction! - - def up - if table_exists?(:ai_topic_embeddings) - loop do - count = execute(<<~SQL).cmd_tuples - INSERT INTO ai_topics_embeddings (topic_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT source.* - FROM ( - SELECT old_table.* - FROM ai_topic_embeddings old_table - LEFT JOIN ai_topics_embeddings target ON ( - target.model_id = old_table.model_id AND - target.strategy_id = old_table.strategy_id AND - target.topic_id = old_table.topic_id - ) - WHERE target.topic_id IS NULL - LIMIT 10000 - ) source - SQL - - break if count == 0 - end - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250114160446_backfill_post_embeddings.rb b/db/migrate/20250114160446_backfill_post_embeddings.rb deleted file mode 100644 index f314f90d..00000000 --- a/db/migrate/20250114160446_backfill_post_embeddings.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true -class BackfillPostEmbeddings < ActiveRecord::Migration[7.2] - disable_ddl_transaction! - - def up - if table_exists?(:ai_post_embeddings) - # Copy data from old tables to new tables in batches. - - loop do - count = execute(<<~SQL).cmd_tuples - INSERT INTO ai_posts_embeddings (post_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT source.* - FROM ( - SELECT old_table.* - FROM ai_post_embeddings old_table - LEFT JOIN ai_posts_embeddings target ON ( - target.model_id = old_table.model_id AND - target.strategy_id = old_table.strategy_id AND - target.post_id = old_table.post_id - ) - WHERE target.post_id IS NULL - LIMIT 10000 - ) source - SQL - - break if count == 0 - end - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250114160500_backfill_rag_embeddings.rb b/db/migrate/20250114160500_backfill_rag_embeddings.rb deleted file mode 100644 index 46f73e7a..00000000 --- a/db/migrate/20250114160500_backfill_rag_embeddings.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true -class BackfillRagEmbeddings < ActiveRecord::Migration[7.2] - def up - if table_exists?(:ai_document_fragment_embeddings) - not_backfilled = - DB.query_single("SELECT COUNT(*) FROM ai_document_fragments_embeddings").first.to_i == 0 - - if not_backfilled - # Copy data from old tables to new tables - execute <<~SQL - INSERT INTO ai_document_fragments_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - SELECT * FROM ai_document_fragment_embeddings; - SQL - end - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250115173456_add_highest_target_number_to_ai_summary.rb b/db/migrate/20250115173456_add_highest_target_number_to_ai_summary.rb deleted file mode 100644 index b998f2c5..00000000 --- a/db/migrate/20250115173456_add_highest_target_number_to_ai_summary.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true -class AddHighestTargetNumberToAiSummary < ActiveRecord::Migration[7.2] - def up - add_column :ai_summaries, :highest_target_number, :integer, null: false, default: 1 - - execute <<~SQL - UPDATE ai_summaries SET highest_target_number = GREATEST(UPPER(content_range) - 1, 1) - SQL - end - - def down - drop_column :ai_summaries, :highest_target_number - end -end diff --git a/db/migrate/20250121162520_configurable_embeddings_prefixes.rb b/db/migrate/20250121162520_configurable_embeddings_prefixes.rb deleted file mode 100644 index 2064ed85..00000000 --- a/db/migrate/20250121162520_configurable_embeddings_prefixes.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true -class ConfigurableEmbeddingsPrefixes < ActiveRecord::Migration[7.2] - def up - add_column :embedding_definitions, :embed_prompt, :string, null: false, default: "" - add_column :embedding_definitions, :search_prompt, :string, null: false, default: "" - - # 4 is bge-large-en. Default model and the only one using this so far. - execute <<~SQL - UPDATE embedding_definitions - SET search_prompt='Represent this sentence for searching relevant passages:' - WHERE id = 4 - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250122003035_add_duration_to_ai_api_log.rb b/db/migrate/20250122003035_add_duration_to_ai_api_log.rb deleted file mode 100644 index a8e99b4a..00000000 --- a/db/migrate/20250122003035_add_duration_to_ai_api_log.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class AddDurationToAiApiLog < ActiveRecord::Migration[7.2] - def change - add_column :ai_api_audit_logs, :duration_msecs, :integer - end -end diff --git a/db/migrate/20250122131007_matryoshka_dimensions_support.rb b/db/migrate/20250122131007_matryoshka_dimensions_support.rb deleted file mode 100644 index a8a38174..00000000 --- a/db/migrate/20250122131007_matryoshka_dimensions_support.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true -class MatryoshkaDimensionsSupport < ActiveRecord::Migration[7.2] - def change - add_column :embedding_definitions, :matryoshka_dimensions, :boolean, null: false, default: false - - execute <<~SQL - UPDATE embedding_definitions - SET matryoshka_dimensions = TRUE - WHERE - provider = 'open_ai' AND - provider_params IS NOT NULL AND - ( - (provider_params->>'model_name') = 'text-embedding-3-large' OR - (provider_params->>'model_name') = 'text-embedding-3-small' - ) - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250125162658_fix_broken_open_ai_embeddings_config.rb b/db/migrate/20250125162658_fix_broken_open_ai_embeddings_config.rb deleted file mode 100644 index 32b6ab80..00000000 --- a/db/migrate/20250125162658_fix_broken_open_ai_embeddings_config.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -class FixBrokenOpenAiEmbeddingsConfig < ActiveRecord::Migration[7.2] - def up - return if fetch_setting("ai_embeddings_selected_model").present? - - return if DB.query_single("SELECT COUNT(*) FROM embedding_definitions").first > 0 - - open_ai_models = %w[text-embedding-3-large text-embedding-3-small text-embedding-ada-002] - current_model = fetch_setting("ai_embeddings_model") - return if !open_ai_models.include?(current_model) - - endpoint = fetch_setting("ai_openai_embeddings_url") || "https://api.openai.com/v1/embeddings" - api_key = fetch_setting("ai_openai_api_key") - return if api_key.blank? - - attrs = { - display_name: current_model, - url: endpoint, - api_key: api_key, - provider: "open_ai", - }.merge(model_attrs(current_model)) - - persist_config(attrs) - end - - def fetch_setting(name) - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: name, - ).first || ENV["DISCOURSE_#{name&.upcase}"] - end - - def model_attrs(model_name) - if model_name == "text-embedding-3-large" - { - dimensions: 2000, - max_sequence_length: 8191, - id: 7, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiTokenizer", - matryoshka_dimensions: true, - provider_params: { - model_name: "text-embedding-3-large", - }, - } - elsif model_name == "text-embedding-3-small" - { - dimensions: 1536, - max_sequence_length: 8191, - id: 6, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiTokenizer", - provider_params: { - model_name: "text-embedding-3-small", - }, - } - else - { - dimensions: 1536, - max_sequence_length: 8191, - id: 2, - pg_function: "<=>", - tokenizer_class: "DiscourseAi::Tokenizer::OpenAiTokenizer", - provider_params: { - model_name: "text-embedding-ada-002", - }, - } - end - end - - def persist_config(attrs) - DB.exec( - <<~SQL, - INSERT INTO embedding_definitions (id, display_name, dimensions, max_sequence_length, version, pg_function, provider, tokenizer_class, url, api_key, provider_params, matryoshka_dimensions, created_at, updated_at) - VALUES (:id, :display_name, :dimensions, :max_sequence_length, 1, :pg_function, :provider, :tokenizer_class, :url, :api_key, :provider_params, :matryoshka_dimensions, :now, :now) - SQL - id: attrs[:id], - display_name: attrs[:display_name], - dimensions: attrs[:dimensions], - max_sequence_length: attrs[:max_sequence_length], - pg_function: attrs[:pg_function], - provider: attrs[:provider], - tokenizer_class: attrs[:tokenizer_class], - url: attrs[:url], - api_key: attrs[:api_key], - provider_params: attrs[:provider_params]&.to_json, - matryoshka_dimensions: !!attrs[:matryoshka_dimensions], - now: Time.zone.now, - ) - - # We hardcoded the ID to match with already generated embeddings. Let's restart the seq to avoid conflicts. - DB.exec( - "ALTER SEQUENCE embedding_definitions_id_seq RESTART WITH :new_seq", - new_seq: attrs[:id].to_i + 1, - ) - - DB.exec(<<~SQL, new_value: attrs[:id]) - INSERT INTO site_settings(name, data_type, value, created_at, updated_at) - VALUES ('ai_embeddings_selected_model', 3, ':new_value', NOW(), NOW()) - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250127145305_clean_unused_embedding_search_indexes.rb b/db/migrate/20250127145305_clean_unused_embedding_search_indexes.rb deleted file mode 100644 index 9c6e0d17..00000000 --- a/db/migrate/20250127145305_clean_unused_embedding_search_indexes.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true -class CleanUnusedEmbeddingSearchIndexes < ActiveRecord::Migration[7.2] - def up - existing_definitions = - DB.query("SELECT id, dimensions FROM embedding_definitions WHERE id <= 8") - - drop_statements = - (1..8) - .reduce([]) do |memo, model_id| - model = existing_definitions.find { |ed| ed&.id == model_id } - - if model.blank? || !correctly_indexed?(model) - embedding_tables.each do |type| - memo << "DROP INDEX IF EXISTS ai_#{type}_embeddings_#{model_id}_1_search_bit;" - end - end - - memo - end - .join("\n") - - DB.exec(drop_statements) if drop_statements.present? - - amend_statements = - (1..8) - .reduce([]) do |memo, model_id| - model = existing_definitions.find { |ed| ed&.id == model_id } - - memo << amended_idxs(model) if model.present? && !correctly_indexed?(model) - - memo - end - .join("\n") - - DB.exec(amend_statements) if amend_statements.present? - end - - def embedding_tables - %w[topics posts document_fragments] - end - - def amended_idxs(model) - embedding_tables.map { |t| <<~SQL }.join("\n") - CREATE INDEX IF NOT EXISTS ai_#{t}_embeddings_#{model.id}_1_search_bit ON ai_#{t}_embeddings - USING hnsw ((binary_quantize(embeddings)::bit(#{model.dimensions})) bit_hamming_ops) - WHERE model_id = #{model.id} AND strategy_id = 1; - SQL - end - - def correctly_indexed?(edef) - seeded_dimensions[edef.id] == edef.dimensions - end - - def seeded_dimensions - { 1 => 768, 2 => 1536, 3 => 1024, 4 => 1024, 5 => 768, 6 => 1536, 7 => 2000, 8 => 1024 } - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250210024600_add_rag_llm_model.rb b/db/migrate/20250210024600_add_rag_llm_model.rb deleted file mode 100644 index bcf8ad79..00000000 --- a/db/migrate/20250210024600_add_rag_llm_model.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true -class AddRagLlmModel < ActiveRecord::Migration[7.2] - def change - add_column :ai_personas, :rag_llm_model_id, :bigint - add_column :ai_tools, :rag_llm_model_id, :bigint - end -end diff --git a/db/migrate/20250210032345_migrate_persona_to_llm_model_id.rb b/db/migrate/20250210032345_migrate_persona_to_llm_model_id.rb deleted file mode 100644 index 4c601703..00000000 --- a/db/migrate/20250210032345_migrate_persona_to_llm_model_id.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -class MigratePersonaToLlmModelId < ActiveRecord::Migration[7.2] - def up - add_column :ai_personas, :default_llm_id, :bigint - add_column :ai_personas, :question_consolidator_llm_id, :bigint - # personas are seeded, we do not mark stuff as readonline - - execute <<~SQL - UPDATE ai_personas - set - default_llm_id = (select id from llm_models where ('custom:' || id) = default_llm), - question_consolidator_llm_id = (select id from llm_models where ('custom:' || id) = question_consolidator_llm) - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250211021037_add_error_to_ai_spam_log.rb b/db/migrate/20250211021037_add_error_to_ai_spam_log.rb deleted file mode 100644 index f1b5a175..00000000 --- a/db/migrate/20250211021037_add_error_to_ai_spam_log.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class AddErrorToAiSpamLog < ActiveRecord::Migration[7.2] - def change - add_column :ai_spam_logs, :error, :string, limit: 3000 - end -end diff --git a/db/migrate/20250310172527_add_ai_search_discoveries_to_user_options.rb b/db/migrate/20250310172527_add_ai_search_discoveries_to_user_options.rb deleted file mode 100644 index 44620c96..00000000 --- a/db/migrate/20250310172527_add_ai_search_discoveries_to_user_options.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddAiSearchDiscoveriesToUserOptions < ActiveRecord::Migration[7.2] - def change - add_column :user_options, :ai_search_discoveries, :boolean, default: true, null: false - end -end diff --git a/db/migrate/20250407125756_set_correct_default_for_short_summarizer_persona.rb b/db/migrate/20250407125756_set_correct_default_for_short_summarizer_persona.rb deleted file mode 100644 index 65e77ee6..00000000 --- a/db/migrate/20250407125756_set_correct_default_for_short_summarizer_persona.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true -class SetCorrectDefaultForShortSummarizerPersona < ActiveRecord::Migration[7.2] - def up - execute <<~SQL - UPDATE ai_personas - SET allowed_group_ids = ARRAY[0] - WHERE id = -12 AND allowed_group_ids = '{}' - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250411121705_add_response_format_json_to_personass.rb b/db/migrate/20250411121705_add_response_format_json_to_personass.rb deleted file mode 100644 index 2f5a3e10..00000000 --- a/db/migrate/20250411121705_add_response_format_json_to_personass.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class AddResponseFormatJsonToPersonass < ActiveRecord::Migration[7.2] - def change - add_column :ai_personas, :response_format, :jsonb - end -end diff --git a/db/migrate/20250416215039_add_cost_metrics_to_llm_model.rb b/db/migrate/20250416215039_add_cost_metrics_to_llm_model.rb deleted file mode 100644 index 1df3ccac..00000000 --- a/db/migrate/20250416215039_add_cost_metrics_to_llm_model.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AddCostMetricsToLlmModel < ActiveRecord::Migration[7.2] - def change - add_column :llm_models, :input_cost, :float - add_column :llm_models, :cached_input_cost, :float - add_column :llm_models, :output_cost, :float - end -end diff --git a/db/migrate/20250417194503_add_max_output_tokens_to_llm_model.rb b/db/migrate/20250417194503_add_max_output_tokens_to_llm_model.rb deleted file mode 100644 index 0272234a..00000000 --- a/db/migrate/20250417194503_add_max_output_tokens_to_llm_model.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddMaxOutputTokensToLlmModel < ActiveRecord::Migration[7.2] - def change - add_column :llm_models, :max_output_tokens, :integer - end -end diff --git a/db/migrate/20250424035234_remove_old_settings.rb b/db/migrate/20250424035234_remove_old_settings.rb deleted file mode 100644 index c97c9ca8..00000000 --- a/db/migrate/20250424035234_remove_old_settings.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true -class RemoveOldSettings < ActiveRecord::Migration[7.2] - def up - execute <<~SQL - DELETE FROM site_settings - WHERE name IN ('ai_bot_enabled_chat_bots') - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250424163718_set_ai_bot_pm_custom_fields.rb b/db/migrate/20250424163718_set_ai_bot_pm_custom_fields.rb deleted file mode 100644 index 02703c5e..00000000 --- a/db/migrate/20250424163718_set_ai_bot_pm_custom_fields.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class SetAiBotPmCustomFields < ActiveRecord::Migration[7.2] - def up - # Set the topic custom field for past bot PMs: - # - Created by a "real" user (user_id > 0) - # - Include exactly 2 participants (creator and 1 bot) - # - One participant is a bot (ID <= -1200) - - execute <<~SQL - INSERT INTO topic_custom_fields (topic_id, name, value, created_at, updated_at) - SELECT t.id, 'is_ai_bot_pm', 't', NOW(), NOW() - FROM topics t - WHERE t.archetype = 'private_message' - AND t.user_id > 0 -- Created by a real user - AND ( - SELECT COUNT(*) - FROM topic_allowed_users tau - WHERE tau.topic_id = t.id - ) = 2 -- Only 2 participants total - AND ( - SELECT COUNT(*) - FROM topic_allowed_users tau - WHERE tau.topic_id = t.id - AND tau.user_id <= -1200 -- Bot users have IDs <= -1200 - ) = 1 -- One of those participants is a bot - AND NOT EXISTS ( - SELECT 1 - FROM topic_custom_fields tcf - WHERE tcf.topic_id = t.id - AND tcf.name = 'is_ai_bot_pm' - ) -- Don't duplicate existing custom fields - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250429060311_move_dall_e_url.rb b/db/migrate/20250429060311_move_dall_e_url.rb deleted file mode 100644 index e818ff3d..00000000 --- a/db/migrate/20250429060311_move_dall_e_url.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true -class MoveDallEUrl < ActiveRecord::Migration[7.2] - def up - execute <<~SQL - UPDATE site_settings - SET name = 'ai_openai_image_generation_url' - WHERE name = 'ai_openai_dall_e_3_url' - AND NOT EXISTS ( - SELECT 1 - FROM site_settings - WHERE name = 'ai_openai_image_generation_url') - SQL - - execute <<~SQL - DELETE FROM site_settings - WHERE name = 'ai_openai_dall_e_3_url' - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb b/db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb deleted file mode 100644 index b272c3b8..00000000 --- a/db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true -class RenamedExperimentalAiBotSetting < ActiveRecord::Migration[7.2] - def up - execute "UPDATE site_settings SET name = 'ai_bot_enable_dedicated_ux' WHERE name = 'ai_enable_experimental_bot_ux'" - end - - def down - execute "UPDATE site_settings SET name = 'ai_enable_experimental_bot_ux' WHERE name = 'ai_bot_enable_dedicated_ux'" - end -end diff --git a/db/migrate/20250508154953_add_examples_to_personas.rb b/db/migrate/20250508154953_add_examples_to_personas.rb deleted file mode 100644 index 2cf12912..00000000 --- a/db/migrate/20250508154953_add_examples_to_personas.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddExamplesToPersonas < ActiveRecord::Migration[7.2] - def change - add_column :ai_personas, :examples, :jsonb - end -end diff --git a/db/migrate/20250508182047_create_inferred_concepts_table.rb b/db/migrate/20250508182047_create_inferred_concepts_table.rb deleted file mode 100644 index 9b612e4d..00000000 --- a/db/migrate/20250508182047_create_inferred_concepts_table.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true -class CreateInferredConceptsTable < ActiveRecord::Migration[7.2] - def change - create_table :inferred_concepts do |t| - t.string :name, null: false - t.timestamps - end - - add_index :inferred_concepts, :name, unique: true - end -end diff --git a/db/migrate/20250508183456_create_inferred_concept_topics.rb b/db/migrate/20250508183456_create_inferred_concept_topics.rb deleted file mode 100644 index 24beee87..00000000 --- a/db/migrate/20250508183456_create_inferred_concept_topics.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class CreateInferredConceptTopics < ActiveRecord::Migration[7.0] - def change - create_table :inferred_concept_topics, id: false do |t| - t.bigint :inferred_concept_id - t.bigint :topic_id - t.timestamps - end - - add_index :inferred_concept_topics, - %i[topic_id inferred_concept_id], - unique: true, - name: "index_inferred_concept_topics_uniqueness" - - add_index :inferred_concept_topics, :inferred_concept_id - end -end diff --git a/db/migrate/20250509000001_create_inferred_concept_posts.rb b/db/migrate/20250509000001_create_inferred_concept_posts.rb deleted file mode 100644 index bcd04b87..00000000 --- a/db/migrate/20250509000001_create_inferred_concept_posts.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class CreateInferredConceptPosts < ActiveRecord::Migration[7.0] - def change - create_table :inferred_concept_posts, id: false do |t| - t.bigint :inferred_concept_id - t.bigint :post_id - t.timestamps - end - - add_index :inferred_concept_posts, - %i[post_id inferred_concept_id], - unique: true, - name: "index_inferred_concept_posts_uniqueness" - - add_index :inferred_concept_posts, :inferred_concept_id - end -end diff --git a/db/migrate/20250607071239_create_ai_artifacts_key_values.rb b/db/migrate/20250607071239_create_ai_artifacts_key_values.rb deleted file mode 100644 index 49bcd93b..00000000 --- a/db/migrate/20250607071239_create_ai_artifacts_key_values.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true -class CreateAiArtifactsKeyValues < ActiveRecord::Migration[7.2] - def change - create_table :ai_artifact_key_values do |t| - t.bigint :ai_artifact_id, null: false - t.integer :user_id, null: false - t.string :key, null: false, limit: 50 - t.string :value, null: false, limit: 20_000 - t.boolean :public, null: false, default: false - t.timestamps - end - - add_index :ai_artifact_key_values, - %i[ai_artifact_id user_id key], - unique: true, - name: "index_ai_artifact_kv_unique" - end -end diff --git a/db/migrate/20250619105705_add_persona_to_ai_moderation_settings.rb b/db/migrate/20250619105705_add_persona_to_ai_moderation_settings.rb deleted file mode 100644 index e514d400..00000000 --- a/db/migrate/20250619105705_add_persona_to_ai_moderation_settings.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -class AddPersonaToAiModerationSettings < ActiveRecord::Migration[7.2] - def change - add_column :ai_moderation_settings, :ai_persona_id, :bigint, null: false, default: -31 - end -end diff --git a/db/migrate/20250620073222_specify_rate_frequency_in_backfill_setting.rb b/db/migrate/20250620073222_specify_rate_frequency_in_backfill_setting.rb deleted file mode 100644 index 6f98377c..00000000 --- a/db/migrate/20250620073222_specify_rate_frequency_in_backfill_setting.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class SpecifyRateFrequencyInBackfillSetting < ActiveRecord::Migration[7.2] - def up - execute "UPDATE site_settings SET name = 'ai_translation_backfill_hourly_rate' WHERE name = 'ai_translation_backfill_rate'" - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250702073222_rename_mixtral_tokenizer_to_mistral_tokenizer.rb b/db/migrate/20250702073222_rename_mixtral_tokenizer_to_mistral_tokenizer.rb deleted file mode 100644 index 260ac898..00000000 --- a/db/migrate/20250702073222_rename_mixtral_tokenizer_to_mistral_tokenizer.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -class RenameMixtralTokenizerToMistralTokenizer < ActiveRecord::Migration[7.2] - def up - execute <<~SQL - UPDATE - llm_models - SET - tokenizer = 'DiscourseAi::Tokenizer::Mistral' - WHERE - tokenizer = 'DiscourseAi::Tokenizer::Mixtral' - SQL - - execute <<~SQL - UPDATE - embedding_definitions - SET - tokenizer_class = 'DiscourseAi::Tokenizer::Mistral' - WHERE - tokenizer_class = 'DiscourseAi::Tokenizer::Mixtral' - SQL - end - - def down - execute <<~SQL - UPDATE - llm_models - SET - tokenizer = 'DiscourseAi::Tokenizer::Mixtral' - WHERE - tokenizer = 'DiscourseAi::Tokenizer::Mistral' - SQL - - execute <<~SQL - UPDATE - embedding_definitions - SET - tokenizer_class = 'DiscourseAi::Tokenizer::Mixtral' - WHERE - tokenizer_class = 'DiscourseAi::Tokenizer::Mistral' - SQL - end -end diff --git a/db/migrate/20250715165701_update_open_ai_embeddings_tokenizer.rb b/db/migrate/20250715165701_update_open_ai_embeddings_tokenizer.rb deleted file mode 100644 index 1d8b3888..00000000 --- a/db/migrate/20250715165701_update_open_ai_embeddings_tokenizer.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true -class UpdateOpenAiEmbeddingsTokenizer < ActiveRecord::Migration[7.2] - def up - execute <<~SQL - UPDATE embedding_definitions - SET tokenizer_class = 'DiscourseAi::Tokenizer::OpenAiCl100kTokenizer' - WHERE url LIKE '%https://api.openai.com/%' AND tokenizer_class <> 'DiscourseAi::Tokenizer::OpenAiCl100kTokenizer' - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250717075002_set_translation_backfill_max_age.rb b/db/migrate/20250717075002_set_translation_backfill_max_age.rb deleted file mode 100644 index 3795cb9f..00000000 --- a/db/migrate/20250717075002_set_translation_backfill_max_age.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true -class SetTranslationBackfillMaxAge < ActiveRecord::Migration[7.2] - def up - execute <<~SQL - UPDATE site_settings - SET value = '20000' - WHERE name = 'ai_translation_backfill_max_age_days' - AND value::integer > 20000; - SQL - - execute <<~SQL - UPDATE site_settings - SET value = '0' - WHERE name = 'ai_translation_backfill_max_age_days' - AND value::integer < 0; - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250721192553_enable_ai_if_already_installed.rb b/db/migrate/20250721192553_enable_ai_if_already_installed.rb deleted file mode 100644 index cdc1d77c..00000000 --- a/db/migrate/20250721192553_enable_ai_if_already_installed.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class EnableAiIfAlreadyInstalled < ActiveRecord::Migration[7.2] - def up - installed_at = DB.query_single(<<~SQL)&.first - SELECT created_at FROM schema_migration_details WHERE version='20230224165056' - SQL - - if installed_at && installed_at < 1.hour.ago - # The plugin was installed before we changed it to be disabled-by-default - # Therefore, if there is no existing database value, enable the plugin - execute <<~SQL - INSERT INTO site_settings(name, data_type, value, created_at, updated_at) - VALUES('discourse_ai_enabled', 5, 't', NOW(), NOW()) - ON CONFLICT (name) DO NOTHING - SQL - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/migrate/20250722082515_add_index_to_ai_topics_embeddings.rb b/db/migrate/20250722082515_add_index_to_ai_topics_embeddings.rb deleted file mode 100644 index b918d654..00000000 --- a/db/migrate/20250722082515_add_index_to_ai_topics_embeddings.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class AddIndexToAiTopicsEmbeddings < ActiveRecord::Migration[7.2] - def up - add_index :ai_topics_embeddings, %i[topic_id model_id] - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/post_migrate/20240724174343_migrate_vision_llms.rb b/db/post_migrate/20240724174343_migrate_vision_llms.rb deleted file mode 100644 index cc2987a9..00000000 --- a/db/post_migrate/20240724174343_migrate_vision_llms.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true -class MigrateVisionLlms < ActiveRecord::Migration[7.1] - def up - vision_models = %w[ - claude-3-sonnet - claude-3-opus - claude-3-haiku - gpt-4-vision-preview - gpt-4-turbo - gpt-4o - gemini-1.5-pro - gemini-1.5-flash - ] - - DB.exec(<<~SQL, names: vision_models) - UPDATE llm_models - SET vision_enabled = true - WHERE name IN (:names) - SQL - - current_value = - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: "ai_helper_image_caption_model", - ).first - - if current_value && current_value != "llava" - model_name = current_value.split(":").last - llm_model = - DB.query_single("SELECT id FROM llm_models WHERE name = :model", model: model_name).first - - if llm_model - DB.exec(<<~SQL, new: "custom:#{llm_model}") if llm_model - UPDATE site_settings - SET value = :new - WHERE name = 'ai_helper_image_caption_model' - SQL - end - end - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/post_migrate/20240729202857_migrate_persona_llm_override.rb b/db/post_migrate/20240729202857_migrate_persona_llm_override.rb deleted file mode 100644 index 99167c02..00000000 --- a/db/post_migrate/20240729202857_migrate_persona_llm_override.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true -class MigratePersonaLlmOverride < ActiveRecord::Migration[7.1] - def up - fields_to_update = DB.query(<<~SQL) - SELECT id, default_llm - FROM ai_personas - WHERE default_llm IS NOT NULL - SQL - - return if fields_to_update.empty? - - updated_fields = - fields_to_update - .map do |field| - llm_model_id = matching_llm_model(field.default_llm) - - "(#{field.id}, 'custom:#{llm_model_id}')" if llm_model_id - end - .compact - - return if updated_fields.empty? - - DB.exec(<<~SQL) - UPDATE ai_personas - SET default_llm = new_fields.new_default_llm - FROM (VALUES #{updated_fields.join(", ")}) AS new_fields(id, new_default_llm) - WHERE new_fields.id::bigint = ai_personas.id - SQL - end - - def matching_llm_model(model) - provider = model.split(":").first - model_name = model.split(":").last - - return if provider == "custom" - - DB.query_single( - "SELECT id FROM llm_models WHERE name = :name AND provider = :provider", - { name: model_name, provider: provider }, - ).first - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/post_migrate/20240809162837_rename_ai_helper_enabled_setting.rb b/db/post_migrate/20240809162837_rename_ai_helper_enabled_setting.rb deleted file mode 100644 index 59dadb96..00000000 --- a/db/post_migrate/20240809162837_rename_ai_helper_enabled_setting.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class RenameAiHelperEnabledSetting < ActiveRecord::Migration[7.1] - def up - execute "UPDATE site_settings SET name = 'ai_helper_enabled' WHERE name = 'composer_ai_helper_enabled'" - end - - def down - execute "UPDATE site_settings SET name = 'composer_ai_helper_enabled' WHERE name = 'ai_helper_enabled'" - end -end diff --git a/db/post_migrate/20240809163303_rename_ai_helper_allowed_groups_setting.rb b/db/post_migrate/20240809163303_rename_ai_helper_allowed_groups_setting.rb deleted file mode 100644 index dc7ae56a..00000000 --- a/db/post_migrate/20240809163303_rename_ai_helper_allowed_groups_setting.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class RenameAiHelperAllowedGroupsSetting < ActiveRecord::Migration[7.1] - def up - execute "UPDATE site_settings SET name = 'composer_ai_helper_allowed_groups' WHERE name = 'ai_helper_allowed_groups'" - end - - def down - execute "UPDATE site_settings SET name = 'ai_helper_allowed_groups' WHERE name = 'composer_ai_helper_allowed_groups'" - end -end diff --git a/db/post_migrate/20240912055831_drop_persona_id_from_rag_document_fragments.rb b/db/post_migrate/20240912055831_drop_persona_id_from_rag_document_fragments.rb deleted file mode 100644 index 238c2968..00000000 --- a/db/post_migrate/20240912055831_drop_persona_id_from_rag_document_fragments.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true -class DropPersonaIdFromRagDocumentFragments < ActiveRecord::Migration[7.1] - def change - execute <<~SQL - UPDATE rag_document_fragments - SET - target_type = 'AiPersona', - target_id = ai_persona_id - WHERE ai_persona_id IS NOT NULL - SQL - - # unlikely but lets be safe - execute <<~SQL - DELETE FROM rag_document_fragments - WHERE target_id IS NULL OR target_type IS NULL - SQL - - remove_column :rag_document_fragments, :ai_persona_id - change_column_null :rag_document_fragments, :target_id, false - change_column_null :rag_document_fragments, :target_type, false - end -end diff --git a/db/post_migrate/20241008055831_drop_old_embeddings_indexes.rb b/db/post_migrate/20241008055831_drop_old_embeddings_indexes.rb deleted file mode 100644 index 347d2b59..00000000 --- a/db/post_migrate/20241008055831_drop_old_embeddings_indexes.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true -class DropOldEmbeddingsIndexes < ActiveRecord::Migration[7.1] - def up - execute <<~SQL - DROP INDEX IF EXISTS ai_topic_embeddings_1_1_search; - DROP INDEX IF EXISTS ai_topic_embeddings_2_1_search; - DROP INDEX IF EXISTS ai_topic_embeddings_3_1_search; - DROP INDEX IF EXISTS ai_topic_embeddings_4_1_search; - DROP INDEX IF EXISTS ai_topic_embeddings_5_1_search; - DROP INDEX IF EXISTS ai_topic_embeddings_6_1_search; - DROP INDEX IF EXISTS ai_topic_embeddings_7_1_search; - DROP INDEX IF EXISTS ai_topic_embeddings_8_1_search; - - DROP INDEX IF EXISTS ai_post_embeddings_1_1_search; - DROP INDEX IF EXISTS ai_post_embeddings_2_1_search; - DROP INDEX IF EXISTS ai_post_embeddings_3_1_search; - DROP INDEX IF EXISTS ai_post_embeddings_4_1_search; - DROP INDEX IF EXISTS ai_post_embeddings_5_1_search; - DROP INDEX IF EXISTS ai_post_embeddings_6_1_search; - DROP INDEX IF EXISTS ai_post_embeddings_7_1_search; - DROP INDEX IF EXISTS ai_post_embeddings_8_1_search; - - DROP INDEX IF EXISTS ai_document_fragment_embeddings_1_1_search; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_2_1_search; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_3_1_search; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_4_1_search; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_5_1_search; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_6_1_search; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_7_1_search; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_8_1_search; - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb b/db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb deleted file mode 100644 index 02d50537..00000000 --- a/db/post_migrate/20241014041242_ai_persona_post_migrate_drop_cols.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true -class AiPersonaPostMigrateDropCols < ActiveRecord::Migration[7.1] - def change - remove_columns :ai_personas, :allow_chat - remove_columns :ai_personas, :mentionable - end -end diff --git a/db/post_migrate/20241031041242_migrate_sentiment_classification_result_format.rb b/db/post_migrate/20241031041242_migrate_sentiment_classification_result_format.rb deleted file mode 100644 index 6c4d425a..00000000 --- a/db/post_migrate/20241031041242_migrate_sentiment_classification_result_format.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true -class MigrateSentimentClassificationResultFormat < ActiveRecord::Migration[7.1] - def up - DB.exec(<<~SQL) - UPDATE classification_results - SET - model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest', - classification = jsonb_build_object( - 'neutral', (classification->>'neutral')::float / 100, - 'negative', (classification->>'negative')::float / 100, - 'positive', (classification->>'positive')::float / 100 - ) - WHERE model_used = 'sentiment'; - - UPDATE classification_results - SET - model_used = 'j-hartmann/emotion-english-distilroberta-base', - classification = jsonb_build_object( - 'sadness', (classification->>'sadness')::float / 100, - 'surprise', (classification->>'surprise')::float / 100, - 'fear', (classification->>'fear')::float / 100, - 'anger', (classification->>'anger')::float / 100, - 'joy', (classification->>'joy')::float / 100, - 'disgust', (classification->>'disgust')::float / 100, - 'neutral', (classification->>'neutral')::float / 100 - ) - WHERE model_used = 'emotion'; - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/post_migrate/20241206115958_rebake_shared_ai_conversation_oneboxes.rb b/db/post_migrate/20241206115958_rebake_shared_ai_conversation_oneboxes.rb deleted file mode 100644 index 73537eb4..00000000 --- a/db/post_migrate/20241206115958_rebake_shared_ai_conversation_oneboxes.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true -class RebakeSharedAiConversationOneboxes < ActiveRecord::Migration[7.2] - def up - # Safe marking for rebake using raw SQL - DB.exec(<<~SQL) - UPDATE posts - SET baked_version = NULL - WHERE raw LIKE '%/discourse-ai/ai-bot/shared-ai-conversations/%'; - SQL - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/post_migrate/20250113171444_drop_old_embedding_tables.rb b/db/post_migrate/20250113171444_drop_old_embedding_tables.rb deleted file mode 100644 index 544dfa4f..00000000 --- a/db/post_migrate/20250113171444_drop_old_embedding_tables.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true -class DropOldEmbeddingTables < ActiveRecord::Migration[7.2] - def up - # Copy rag embeddings created during deploy. - # noop. TODO(roman): Will follow-up with a new migration to drop these tables. - end - - def down - end -end diff --git a/db/post_migrate/20250114184356_drop_old_embedding_tables2.rb b/db/post_migrate/20250114184356_drop_old_embedding_tables2.rb deleted file mode 100644 index 6b873924..00000000 --- a/db/post_migrate/20250114184356_drop_old_embedding_tables2.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true -class DropOldEmbeddingTables2 < ActiveRecord::Migration[7.2] - def up - if table_exists?(:ai_document_fragment_embeddings) - # Copy rag embeddings created during deploy. - execute <<~SQL - INSERT INTO ai_document_fragments_embeddings (rag_document_fragment_id, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - ( - SELECT old_table.* - FROM ai_document_fragment_embeddings old_table - LEFT OUTER JOIN ai_document_fragments_embeddings target ON ( - target.model_id = old_table.model_id AND - target.strategy_id = old_table.strategy_id AND - target.rag_document_fragment_id = old_table.rag_document_fragment_id - ) - WHERE target.rag_document_fragment_id IS NULL - ) - SQL - end - - execute <<~SQL - DROP INDEX IF EXISTS ai_topic_embeddings_1_1_search_bit; - DROP INDEX IF EXISTS ai_topic_embeddings_2_1_search_bit; - DROP INDEX IF EXISTS ai_topic_embeddings_3_1_search_bit; - DROP INDEX IF EXISTS ai_topic_embeddings_4_1_search_bit; - DROP INDEX IF EXISTS ai_topic_embeddings_5_1_search_bit; - DROP INDEX IF EXISTS ai_topic_embeddings_6_1_search_bit; - DROP INDEX IF EXISTS ai_topic_embeddings_7_1_search_bit; - DROP INDEX IF EXISTS ai_topic_embeddings_8_1_search_bit; - DROP INDEX IF EXISTS ai_post_embeddings_1_1_search_bit; - DROP INDEX IF EXISTS ai_post_embeddings_2_1_search_bit; - DROP INDEX IF EXISTS ai_post_embeddings_3_1_search_bit; - DROP INDEX IF EXISTS ai_post_embeddings_4_1_search_bit; - DROP INDEX IF EXISTS ai_post_embeddings_5_1_search_bit; - DROP INDEX IF EXISTS ai_post_embeddings_6_1_search_bit; - DROP INDEX IF EXISTS ai_post_embeddings_7_1_search_bit; - DROP INDEX IF EXISTS ai_post_embeddings_8_1_search_bit; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_1_1_search_bit; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_2_1_search_bit; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_3_1_search_bit; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_4_1_search_bit; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_5_1_search_bit; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_6_1_search_bit; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_7_1_search_bit; - DROP INDEX IF EXISTS ai_document_fragment_embeddings_8_1_search_bit; - SQL - - drop_table :ai_topic_embeddings, if_exists: true - drop_table :ai_post_embeddings, if_exists: true - drop_table :ai_document_fragment_embeddings, if_exists: true - end - - def down - end -end diff --git a/db/post_migrate/20250115181147_drop_ai_summaries_content_range.rb b/db/post_migrate/20250115181147_drop_ai_summaries_content_range.rb deleted file mode 100644 index 846c3e1e..00000000 --- a/db/post_migrate/20250115181147_drop_ai_summaries_content_range.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true -class DropAiSummariesContentRange < ActiveRecord::Migration[7.2] - DROPPED_COLUMNS = { ai_summaries: %i[content_range] } - - def up - DROPPED_COLUMNS.each { |table, columns| Migration::ColumnDropper.execute_drop(table, columns) } - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/post_migrate/20250210032351_post_migrate_persona_to_llm_model_id.rb b/db/post_migrate/20250210032351_post_migrate_persona_to_llm_model_id.rb deleted file mode 100644 index 0d161b56..00000000 --- a/db/post_migrate/20250210032351_post_migrate_persona_to_llm_model_id.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true -class PostMigratePersonaToLlmModelId < ActiveRecord::Migration[7.2] - def up - remove_column :ai_personas, :default_llm - remove_column :ai_personas, :question_consolidator_llm - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/db/post_migrate/20250404045050_migrate_users_to_email_group.rb b/db/post_migrate/20250404045050_migrate_users_to_email_group.rb deleted file mode 100644 index 3711a2d3..00000000 --- a/db/post_migrate/20250404045050_migrate_users_to_email_group.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true -class MigrateUsersToEmailGroup < ActiveRecord::Migration[7.2] - def up - execute <<~SQL - UPDATE discourse_automation_fields - SET component = 'email_group_user' - WHERE - component = 'users' AND - name = 'receivers' AND - automation_id IN (SELECT id FROM discourse_automation_automations WHERE script = 'llm_report') - SQL - end - - def down - execute <<~SQL - UPDATE discourse_automation_fields - SET component = 'users' - WHERE - component = 'email_group_user' AND - name = 'receivers' AND - automation_id IN (SELECT id FROM discourse_automation_automations WHERE script = 'llm_report') - SQL - end -end diff --git a/discourse_automation/llm_persona_triage.rb b/discourse_automation/llm_persona_triage.rb deleted file mode 100644 index cbe6c1ae..00000000 --- a/discourse_automation/llm_persona_triage.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -if defined?(DiscourseAutomation) - DiscourseAutomation::Scriptable.add("llm_persona_triage") do - version 1 - run_in_background - - triggerables %i[post_created_edited] - - field :persona, - component: :choices, - required: true, - extra: { - content: DiscourseAi::Automation.available_persona_choices, - } - field :whisper, component: :boolean - field :silent_mode, component: :boolean - - script do |context, fields| - post = context["post"] - next if post&.user&.bot? - - persona_id = fields.dig("persona", "value") - whisper = !!fields.dig("whisper", "value") - silent_mode = !!fields.dig("silent_mode", "value") - - begin - RateLimiter.new( - Discourse.system_user, - "llm_persona_triage_#{post.id}", - SiteSetting.ai_automation_max_triage_per_post_per_minute, - 1.minute, - ).performed! - - RateLimiter.new( - Discourse.system_user, - "llm_persona_triage", - SiteSetting.ai_automation_max_triage_per_minute, - 1.minute, - ).performed! - - DiscourseAi::Automation::LlmPersonaTriage.handle( - post: post, - persona_id: persona_id, - whisper: whisper, - automation: self.automation, - silent_mode: silent_mode, - ) - rescue => e - Discourse.warn_exception( - e, - message: "llm_persona_triage: skipped triage on post #{post.id}", - ) - raise e if Rails.env.tests? - end - end - end -end diff --git a/discourse_automation/llm_report.rb b/discourse_automation/llm_report.rb deleted file mode 100644 index c5e308d8..00000000 --- a/discourse_automation/llm_report.rb +++ /dev/null @@ -1,134 +0,0 @@ -# frozen_string_literal: true - -if defined?(DiscourseAutomation) - module DiscourseAutomation::LlmReport - end - - DiscourseAutomation::Scriptable.add("llm_report") do - version 1 - triggerables %i[recurring] - - field :sender, component: :user, required: true - field :receivers, component: :email_group_user - field :topic_id, component: :text - field :title, component: :text - field :days, component: :text, required: true, default_value: 7 - field :offset, component: :text, required: true, default_value: 0 - field :instructions, - component: :message, - required: true, - default_value: DiscourseAi::Automation::ReportRunner.default_instructions - field :sample_size, component: :text, required: true, default_value: 100 - field :tokens_per_post, component: :text, required: true, default_value: 150 - - field :persona_id, - component: :choices, - required: true, - default_value: - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - extra: { - content: - DiscourseAi::Automation.available_persona_choices( - require_user: false, - require_default_llm: false, - ), - } - field :model, - component: :choices, - required: true, - extra: { - content: DiscourseAi::Automation.available_models, - } - - field :priority_group, component: :group - field :categories, component: :categories - field :tags, component: :tags - - field :exclude_categories, component: :categories - field :exclude_tags, component: :tags - - field :allow_secure_categories, component: :boolean - - field :top_p, component: :text - field :temperature, component: :text - - field :suppress_notifications, component: :boolean - field :debug_mode, component: :boolean - - script do |context, fields, automation| - begin - sender = fields.dig("sender", "value") - receivers = fields.dig("receivers", "value") - topic_id = fields.dig("topic_id", "value") - title = fields.dig("title", "value") - model = fields.dig("model", "value") - category_ids = fields.dig("categories", "value") - tags = fields.dig("tags", "value") - allow_secure_categories = !!fields.dig("allow_secure_categories", "value") - debug_mode = !!fields.dig("debug_mode", "value") - sample_size = fields.dig("sample_size", "value") - instructions = fields.dig("instructions", "value") - days = fields.dig("days", "value") - offset = fields.dig("offset", "value").to_i - priority_group = fields.dig("priority_group", "value") - tokens_per_post = fields.dig("tokens_per_post", "value") - persona_id = fields.dig("persona_id", "value") - - exclude_category_ids = fields.dig("exclude_categories", "value") - exclude_tags = fields.dig("exclude_tags", "value") - - top_p = fields.dig("top_p", "value") - if top_p == "" || top_p.nil? - top_p = nil - else - top_p = top_p.to_f - end - - temperature = fields.dig("temperature", "value") - if temperature == "" || temperature.nil? - temperature = nil - else - temperature = temperature.to_f - end - - # Backwards-compat for scripts created before this field was added. - if persona_id == "" || persona_id.nil? - persona_id = - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner] - end - - suppress_notifications = !!fields.dig("suppress_notifications", "value") - DiscourseAi::Automation::ReportRunner.run!( - sender_username: sender, - receivers: receivers, - topic_id: topic_id, - title: title, - persona_id: persona_id, - model: model, - category_ids: category_ids, - tags: tags, - allow_secure_categories: allow_secure_categories, - debug_mode: debug_mode, - sample_size: sample_size, - instructions: instructions, - days: days, - offset: offset, - priority_group_id: priority_group, - tokens_per_post: tokens_per_post, - exclude_category_ids: exclude_category_ids, - exclude_tags: exclude_tags, - temperature: temperature, - top_p: top_p, - suppress_notifications: suppress_notifications, - automation: self.automation, - ) - rescue => e - Discourse.warn_exception e, message: "Error running LLM report!" - if Rails.env.development? - p e - puts e.backtrace - end - end - end - end -end diff --git a/discourse_automation/llm_tool_triage.rb b/discourse_automation/llm_tool_triage.rb deleted file mode 100644 index 885f81fb..00000000 --- a/discourse_automation/llm_tool_triage.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -# TODO: this is still highly experimental and subject to a lot of change -# leaving it off in production for now Sam -if defined?(DiscourseAutomation) && !Rails.env.production? - DiscourseAutomation::Scriptable.add("llm_tool_triage") do - version 1 - run_in_background - - triggerables %i[post_created_edited] - - field :tool, - component: :choices, - required: true, - extra: { - content: DiscourseAi::Automation.available_custom_tools, - } - - script do |context, fields| - tool_id = fields["tool"]["value"] - post = context["post"] - return if post&.user&.bot? - - begin - RateLimiter.new( - Discourse.system_user, - "llm_tool_triage_#{post.id}", - SiteSetting.ai_automation_max_triage_per_post_per_minute, - 1.minute, - ).performed! - - RateLimiter.new( - Discourse.system_user, - "llm_tool_triage", - SiteSetting.ai_automation_max_triage_per_minute, - 1.minute, - ).performed! - - DiscourseAi::Automation::LlmToolTriage.handle( - post: post, - tool_id: tool_id, - automation: self.automation, - ) - rescue => e - Discourse.warn_exception(e, message: "llm_tool_triage: skipped triage on post #{post.id}") - end - end - end -end diff --git a/discourse_automation/llm_triage.rb b/discourse_automation/llm_triage.rb deleted file mode 100644 index cda48c84..00000000 --- a/discourse_automation/llm_triage.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -if defined?(DiscourseAutomation) - DiscourseAutomation::Scriptable.add("llm_triage") do - version 1 - run_in_background - - placeholder :post - - triggerables %i[post_created_edited] - - # TODO move to triggerables - field :include_personal_messages, component: :boolean - - # Inputs - field :model, - component: :choices, - required: true, - extra: { - content: DiscourseAi::Automation.available_models, - } - field :system_prompt, component: :message, required: false - field :search_for_text, component: :text, required: true - field :max_post_tokens, component: :text - field :stop_sequences, component: :text_list, required: false - field :temperature, component: :text - field :max_output_tokens, component: :text - - # Actions - field :category, component: :category - field :tags, component: :tags - field :hide_topic, component: :boolean - field :flag_post, component: :boolean - field :flag_type, - component: :choices, - required: false, - extra: { - content: DiscourseAi::Automation.flag_types, - }, - default: "review" - field :canned_reply_user, component: :user - field :canned_reply, component: :message - field :reply_persona, - component: :choices, - extra: { - content: - DiscourseAi::Automation.available_persona_choices( - require_user: false, - require_default_llm: true, - ), - } - field :whisper, component: :boolean - - script do |context, fields| - post = context["post"] - next if post&.user&.bot? - - if post.topic.private_message? - include_personal_messages = fields.dig("include_personal_messages", "value") - next if !include_personal_messages - end - - canned_reply = fields.dig("canned_reply", "value") - canned_reply_user = fields.dig("canned_reply_user", "value") - reply_persona_id = fields.dig("reply_persona", "value") - whisper = fields.dig("whisper", "value") - - # nothing to do if we already replied - next if post.user.username == canned_reply_user - next if post.raw.strip == canned_reply.to_s.strip - - system_prompt = fields.dig("system_prompt", "value") - search_for_text = fields.dig("search_for_text", "value") - model = fields.dig("model", "value") - - category_id = fields.dig("category", "value") - tags = fields.dig("tags", "value") - hide_topic = fields.dig("hide_topic", "value") - flag_post = fields.dig("flag_post", "value") - flag_type = fields.dig("flag_type", "value") - max_post_tokens = fields.dig("max_post_tokens", "value").to_i - temperature = fields.dig("temperature", "value") - if temperature == "" || temperature.nil? - temperature = nil - else - temperature = temperature.to_f - end - - max_output_tokens = fields.dig("max_output_tokens", "value").to_i - max_output_tokens = nil if max_output_tokens <= 0 - - max_post_tokens = nil if max_post_tokens <= 0 - - stop_sequences = fields.dig("stop_sequences", "value") - - begin - RateLimiter.new( - Discourse.system_user, - "llm_triage_#{post.id}", - SiteSetting.ai_automation_max_triage_per_post_per_minute, - 1.minute, - ).performed! - - RateLimiter.new( - Discourse.system_user, - "llm_triage", - SiteSetting.ai_automation_max_triage_per_minute, - 1.minute, - ).performed! - - DiscourseAi::Automation::LlmTriage.handle( - post: post, - model: model, - search_for_text: search_for_text, - system_prompt: system_prompt, - category_id: category_id, - tags: tags, - canned_reply: canned_reply, - canned_reply_user: canned_reply_user, - reply_persona_id: reply_persona_id, - whisper: whisper, - hide_topic: hide_topic, - flag_post: flag_post, - flag_type: flag_type.to_s.to_sym, - max_post_tokens: max_post_tokens, - stop_sequences: stop_sequences, - automation: self.automation, - temperature: temperature, - max_output_tokens: max_output_tokens, - action: context["action"], - ) - rescue => e - Discourse.warn_exception( - e, - message: "llm_triage: skipped triage on post #{post.id} #{post.url}", - ) - end - end - end -end diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index e691ec7f..00000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import DiscourseRecommended from "@discourse/lint-configs/eslint"; - -export default [...DiscourseRecommended]; diff --git a/evals/lib/boot.rb b/evals/lib/boot.rb deleted file mode 100644 index 11120dc5..00000000 --- a/evals/lib/boot.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -# got to ensure evals are here -# rubocop:disable Discourse/Plugins/NamespaceConstants -EVAL_PATH = File.join(__dir__, "../cases") -# rubocop:enable Discourse/Plugins/NamespaceConstants -# -if !Dir.exist?(EVAL_PATH) - puts "Evals are missing, cloning from discourse/discourse-ai-evals" - - success = - system("git clone git@github.com:discourse/discourse-ai-evals.git '#{EVAL_PATH}' 2>/dev/null") - - # Fall back to HTTPS if SSH fails - if !success - puts "SSH clone failed, falling back to HTTPS..." - success = system("git clone https://github.com/discourse/discourse-ai-evals.git '#{EVAL_PATH}'") - end - - if success - puts "Successfully cloned evals repository" - else - abort "Failed to clone evals repository" - end -end - -discourse_path = ENV["DISCOURSE_PATH"] || File.expand_path(File.join(__dir__, "../../../..")) -# rubocop:disable Discourse/NoChdir -Dir.chdir(discourse_path) -# rubocop:enable Discourse/NoChdir - -require "#{discourse_path}/config/environment" - -ENV["DISCOURSE_AI_NO_DEBUG"] = "1" -module DiscourseAi::Evals -end diff --git a/evals/lib/cli.rb b/evals/lib/cli.rb deleted file mode 100644 index e28658ea..00000000 --- a/evals/lib/cli.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true -require "optparse" - -class DiscourseAi::Evals::Cli - class Options - attr_accessor :eval_name, :model, :list, :list_models - def initialize(eval_name: nil, model: nil, list: false, list_models: false) - @eval_name = eval_name - @model = model - @list = list - @list_models = list_models - end - end - - def self.parse_options! - options = Options.new - - parser = - OptionParser.new do |opts| - opts.banner = "Usage: evals/run [options]" - - opts.on("-e", "--eval NAME", "Name of the evaluation to run") do |eval_name| - options.eval_name = eval_name - end - - opts.on("--list-models", "List models") { |model| options.list_models = true } - - opts.on( - "-m", - "--model NAME", - "Model to evaluate (will eval all models if not specified)", - ) { |model| options.model = model } - - opts.on("-l", "--list", "List evals") { |model| options.list = true } - end - - show_help = ARGV.empty? - parser.parse! - - if show_help - puts parser - exit 0 - end - - options - end -end diff --git a/evals/lib/eval.rb b/evals/lib/eval.rb deleted file mode 100644 index d322bed8..00000000 --- a/evals/lib/eval.rb +++ /dev/null @@ -1,329 +0,0 @@ -#frozen_string_literal: true - -class DiscourseAi::Evals::Eval - attr_reader :type, - :path, - :name, - :description, - :id, - :args, - :vision, - :expected_output, - :expected_output_regex, - :expected_tool_call, - :judge - - class EvalError < StandardError - attr_reader :context - - def initialize(message, context) - super(message) - @context = context - end - end - - def initialize(path:) - @yaml = YAML.load_file(path).symbolize_keys - @path = path - @name = @yaml[:name] - @id = @yaml[:id] - @description = @yaml[:description] - @vision = @yaml[:vision] - @type = @yaml[:type] - @expected_output = @yaml[:expected_output] - @expected_output_regex = @yaml[:expected_output_regex] - @expected_output_regex = - Regexp.new(@expected_output_regex, Regexp::MULTILINE) if @expected_output_regex - @expected_tool_call = @yaml[:expected_tool_call] - @expected_tool_call.symbolize_keys! if @expected_tool_call - @judge = @yaml[:judge] - @judge.symbolize_keys! if @judge - if @yaml[:args].is_a?(Array) - @args = @yaml[:args].map(&:symbolize_keys) - else - @args = @yaml[:args].symbolize_keys - @args.each do |key, value| - if (key.to_s.include?("_path") || key.to_s == "path") && value.is_a?(String) - @args[key] = File.expand_path(File.join(File.dirname(path), value)) - end - end - end - end - - def run(llm:) - result = - case type - when "helper" - helper(llm, **args) - when "pdf_to_text" - pdf_to_text(llm, **args) - when "image_to_text" - image_to_text(llm, **args) - when "prompt" - DiscourseAi::Evals::PromptEvaluator.new(llm).prompt_call(args) - when "edit_artifact" - edit_artifact(llm, **args) - when "summarization" - summarization(llm, **args) - end - - classify_results(result) - rescue EvalError => e - { result: :fail, message: e.message, context: e.context } - end - - def print - puts "#{id}: #{description}" - end - - def to_json - { - type: @type, - path: @path, - name: @name, - description: @description, - id: @id, - args: @args, - vision: @vision, - expected_output: @expected_output, - expected_output_regex: @expected_output_regex, - }.compact - end - - private - - # @param result [String, Array] the result of the eval, either - # "llm response" or [{ result: "llm response", other_attrs: here }] - # @return [Array] an array of hashes with the result classified - # as pass or fail, along with extra attributes - def classify_results(result) - if result.is_a?(Array) - result.each { |r| r.merge!(classify_result_pass_fail(r)) } - else - [classify_result_pass_fail(result)] - end - end - - def classify_result_pass_fail(result) - if expected_output - if result == expected_output - { result: :pass } - else - { result: :fail, expected_output: expected_output, actual_output: result } - end - elsif expected_output_regex - if result.to_s.match?(expected_output_regex) - { result: :pass } - else - { result: :fail, expected_output: expected_output_regex, actual_output: result } - end - elsif expected_tool_call - tool_call = result - - if result.is_a?(Array) - tool_call = result.find { |r| r.is_a?(DiscourseAi::Completions::ToolCall) } - end - if !tool_call.is_a?(DiscourseAi::Completions::ToolCall) || - (tool_call.name != expected_tool_call[:name]) || - (tool_call.parameters != expected_tool_call[:params]) - { result: :fail, expected_output: expected_tool_call, actual_output: result } - else - { result: :pass } - end - elsif judge - judge_result(result) - else - { result: :pass } - end - end - - def judge_result(result) - prompt = judge[:prompt].dup - if result.is_a?(String) - prompt.sub!("{{output}}", result) - args.each { |key, value| prompt.sub!("{{#{key}}}", value.to_s) } - else - prompt.sub!("{{output}}", result[:result]) - result.each { |key, value| prompt.sub!("{{#{key}}}", value.to_s) } - end - - prompt += <<~SUFFIX - - Reply with a rating from 1 to 10, where 10 is perfect and 1 is terrible. - - example output: - - [RATING]10[/RATING] perfect output - - example output: - - [RATING]5[/RATING] - - the following failed to preserve... etc... - SUFFIX - - judge_llm = DiscourseAi::Evals::Llm.choose(judge[:llm]).first - - DiscourseAi::Completions::Prompt.new( - "You are an expert judge tasked at testing LLM outputs.", - messages: [{ type: :user, content: prompt }], - ) - - result = - judge_llm.llm_model.to_llm.generate(prompt, user: Discourse.system_user, temperature: 0) - - if rating = result.match(%r{\[RATING\](\d+)\[/RATING\]}) - rating = rating[1].to_i - end - - if rating.to_i >= judge[:pass_rating] - { result: :pass } - else - { - result: :fail, - message: "LLM Rating below threshold, it was #{rating}, expecting #{judge[:pass_rating]}", - context: result, - } - end - end - - def helper(llm, input:, name:, locale: nil) - helper = DiscourseAi::AiHelper::Assistant.new(helper_llm: llm.llm_model) - user = Discourse.system_user - if locale - user = User.new - class << user - attr_accessor :effective_locale - end - - user.effective_locale = locale - user.admin = true - end - result = - helper.generate_and_send_prompt(name, input, current_user = user, force_default_locale: false) - - result[:suggestions].first - end - - def image_to_text(llm, path:) - upload = - UploadCreator.new(File.open(path), File.basename(path)).create_for(Discourse.system_user.id) - - text = +"" - DiscourseAi::Utils::ImageToText - .new(upload: upload, llm_model: llm.llm_model, user: Discourse.system_user) - .extract_text do |chunk, error| - text << chunk if chunk - text << "\n\n" if chunk - end - text - ensure - upload.destroy if upload - end - - def pdf_to_text(llm, path:) - upload = - UploadCreator.new(File.open(path), File.basename(path)).create_for(Discourse.system_user.id) - - text = +"" - DiscourseAi::Utils::PdfToText - .new(upload: upload, user: Discourse.system_user, llm_model: llm.llm_model) - .extract_text do |chunk| - text << chunk if chunk - text << "\n\n" if chunk - end - - text - ensure - upload.destroy if upload - end - - def edit_artifact(llm, css_path:, js_path:, html_path:, instructions_path:) - css = File.read(css_path) - js = File.read(js_path) - html = File.read(html_path) - instructions = File.read(instructions_path) - artifact = - AiArtifact.create!( - css: css, - js: js, - html: html, - user_id: Discourse.system_user.id, - post_id: 1, - name: "eval artifact", - ) - - post = Post.new(topic_id: 1, id: 1) - diff = - DiscourseAi::AiBot::ArtifactUpdateStrategies::Diff.new( - llm: llm.llm_model.to_llm, - post: post, - user: Discourse.system_user, - artifact: artifact, - artifact_version: nil, - instructions: instructions, - ) - diff.apply - - if diff.failed_searches.present? - puts "Eval Errors encountered" - p diff.failed_searches - raise EvalError.new("Failed to apply all changes", diff.failed_searches) - end - - version = artifact.versions.last - raise EvalError.new("Invalid JS", version.js) if !valid_javascript?(version.js) - - output = { css: version.css, js: version.js, html: version.html } - - artifact.destroy - output - end - - def valid_javascript?(str) - require "open3" - - # Create a temporary file with the JavaScript code - Tempfile.create(%w[test .js]) do |f| - f.write(str) - f.flush - - File.write("/tmp/test.js", str) - - begin - Discourse::Utils.execute_command( - "node", - "--check", - f.path, - failure_message: "Invalid JavaScript syntax", - timeout: 30, # reasonable timeout in seconds - ) - true - rescue Discourse::Utils::CommandError - false - end - end - rescue StandardError - false - end - - def summarization(llm, input:) - topic = - Topic.new( - category: Category.last, - title: "Eval topic for topic summarization", - id: -99, - user_id: Discourse.system_user.id, - ) - Post.new(topic: topic, id: -99, user_id: Discourse.system_user.id, raw: input) - - strategy = - DiscourseAi::Summarization::FoldContent.new( - llm.llm_proxy, - DiscourseAi::Summarization::Strategies::TopicSummary.new(topic), - ) - - summary = DiscourseAi::TopicSummarization.new(strategy, Discourse.system_user).summarize - summary.summarized_text - end -end diff --git a/evals/lib/llm.rb b/evals/lib/llm.rb deleted file mode 100644 index bb1d40de..00000000 --- a/evals/lib/llm.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Evals::Llm - def self.configs - return @configs if @configs - - yaml_path = File.join(File.dirname(__FILE__), "../../config/eval-llms.yml") - local_yaml_path = File.join(File.dirname(__FILE__), "../../config/eval-llms.local.yml") - - configs = YAML.load_file(yaml_path)["llms"] || {} - if File.exist?(local_yaml_path) - local_configs = YAML.load_file(local_yaml_path)["llms"] || {} - configs = configs.merge(local_configs) - end - - @configs = configs - end - - def self.print - configs - .keys - .map do |config_name| - begin - new(config_name) - rescue StandardError - nil - end - end - .compact - .each { |llm| puts "#{llm.config_name}: #{llm.name} (#{llm.provider})" } - end - - def self.choose(config_name) - return [] unless configs - if !config_name || !configs[config_name] - configs - .keys - .map do |name| - begin - new(name) - rescue StandardError - nil - end - end - .compact - else - [new(config_name)] - end - end - - attr_reader :llm_model, :llm_proxy, :config_name - - def initialize(config_name) - config = self.class.configs[config_name].dup - if config["api_key_env"] - api_key_env = config.delete("api_key_env") - unless ENV[api_key_env] - raise "Missing API key for #{config_name}, should be set via #{api_key_env}" - end - config[:api_key] = ENV[api_key_env] - elsif config["api_key"] - config[:api_key] = config.delete("api_key") - else - raise "No API key or API key env var configured for #{config_name}" - end - @llm_model = LlmModel.new(config.symbolize_keys) - @llm_proxy = DiscourseAi::Completions::Llm.proxy(@llm_model) - @config_name = config_name - end - - def provider - @llm_model.provider - end - - def name - @llm_model.display_name - end - - def vision? - @llm_model.vision_enabled - end -end diff --git a/evals/lib/prompts/prompt_evaluator.rb b/evals/lib/prompts/prompt_evaluator.rb deleted file mode 100644 index d526243d..00000000 --- a/evals/lib/prompts/prompt_evaluator.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Evals::PromptEvaluator - def initialize(llm) - @llm = llm.llm_model.to_llm - end - - def prompt_call(args) - args = [args] if !args.is_a?(Array) - runner = DiscourseAi::Evals::PromptSingleTestRunner.new(@llm) - - with_tests_progress(total: args.size) do |bump_progress| - args.flat_map do |test| - bump_progress.call - - prompts, messages, followups, output_thinking, stream, temperature, tools = - symbolize_test_args(test) - - prompts.flat_map do |prompt| - messages.map do |message| - runner.run_single_test( - prompt, - message, - followups, - output_thinking, - stream, - temperature, - tools, - ) - end - end - end - end - end - - private - - def symbolize_test_args(args) - prompts = args[:prompts] || [args[:prompt]] - messages = args[:messages] || [args[:message]] - followups = symbolize_followups(args) - output_thinking = args[:output_thinking] || false - stream = args[:stream] || false - temperature = args[:temperature] - tools = symbolize_tools(args[:tools]) - [prompts, messages, followups, output_thinking, stream, temperature, tools] - end - - def symbolize_followups(args) - return nil if args[:followups].nil? && args[:followup].nil? - followups = args[:followups] || [args[:followup]] - followups.map do |followup| - followup = followup.dup.symbolize_keys! - message = followup[:message].dup.symbolize_keys! - message[:type] = message[:type].to_sym if message[:type] - followup[:message] = message - followup - end - end - - def symbolize_tools(tools) - return nil if tools.nil? - tools.map do |tool| - tool.symbolize_keys! - tool.merge( - parameters: tool[:parameters]&.map { |param| param.transform_keys(&:to_sym) }, - ).compact - end - end - - def with_tests_progress(total:) - puts "" - count = 0 - result = - yield( - -> do - count += 1 - print "\rProcessing test #{count}/#{total}" - end - ) - print "\r\033[K" - result - end -end diff --git a/evals/lib/prompts/single_test_runner.rb b/evals/lib/prompts/single_test_runner.rb deleted file mode 100644 index 6e7c43f8..00000000 --- a/evals/lib/prompts/single_test_runner.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Evals::PromptSingleTestRunner - def initialize(llm) - @llm = llm - end - - # Run a single test with a prompt and message, and some model settings - # @param prompt [String] the prompt to use - # @param message [String] the message to use - # @param followups [Array] an array of followups (messages) to run after the initial prompt - # @param output_thinking [Boolean] whether to output the thinking state of the model - # @param stream [Boolean] whether to stream the output of the model - # @param temperature [Float] the temperature to use when generating completions - # @param tools [Array] an array of tools to use when generating completions - # @return [Hash] the prompt, message, and result of the test - def run_single_test(prompt, message, followups, output_thinking, stream, temperature, tools) - @c_prompt = - DiscourseAi::Completions::Prompt.new(prompt, messages: [{ type: :user, content: message }]) - @c_prompt.tools = tools if tools - generate_result(temperature, output_thinking, stream) - - if followups - followups.each do |followup| - generate_followup(followup, output_thinking, stream, temperature) - end - end - - { prompt:, message:, result: @result } - end - - private - - def generate_followup(followup, output_thinking, stream, temperature) - @c_prompt.push_model_response(@result) - followup_message = set_followup_tool(followup) - @c_prompt.push(**followup_message) - begin - generate_result(temperature, output_thinking, stream) - rescue => e - # should not happen but it helps debugging... - puts e - result = [] - end - end - - def set_followup_tool(followup) - @c_prompt.tools = followup[:tools] if followup[:tools] - followup_message = followup[:message] - %i[id name].each do |key| - if followup_message[key].is_a?(Array) - type, inner_key = followup_message[key] - # this allows us to dynamically set the id or name of the tool call - prev = @c_prompt.messages.reverse.find { |m| m[:type] == type.to_sym } - followup_message[key] = prev[inner_key.to_sym] if prev - end - end - followup_message - end - - def generate_result(temperature, output_thinking, stream) - @result = - if stream - stream_result = [] - @llm.generate( - @c_prompt, - user: Discourse.system_user, - temperature:, - output_thinking:, - ) { |partial| stream_result << partial } - stream_result - else - @llm.generate(@c_prompt, user: Discourse.system_user, temperature:, output_thinking:) - end - end -end diff --git a/evals/lib/runner.rb b/evals/lib/runner.rb deleted file mode 100644 index e72fa1e0..00000000 --- a/evals/lib/runner.rb +++ /dev/null @@ -1,194 +0,0 @@ -#frozen_string_literal: true - -class DiscourseAi::Evals::Runner - class StructuredLogger - def initialize - @log = [] - @current_step = @log - end - - def log(name, args: nil, start_time: nil, end_time: nil) - start_time ||= Time.now.utc - end_time ||= Time.now.utc - args ||= {} - object = { name: name, args: args, start_time: start_time, end_time: end_time } - @current_step << object - end - - def step(name, args: nil) - start_time = Time.now.utc - start_step = @current_step - - new_step = { type: :step, name: name, args: args || {}, log: [], start_time: start_time } - - @current_step << new_step - @current_step = new_step[:log] - yield new_step - @current_step = start_step - new_step[:end_time] = Time.now.utc - end - - def to_trace_event_json - trace_events = [] - process_id = 1 - thread_id = 1 - - to_trace_event(@log, process_id, thread_id, trace_events) - - JSON.pretty_generate({ traceEvents: trace_events }) - end - - private - - def to_trace_event(log_items, pid, tid, trace_events, parent_start_time = nil) - log_items.each do |item| - if item.is_a?(Hash) && item[:type] == :step - trace_events << { - name: item[:name], - cat: "default", - ph: "B", # Begin event - pid: pid, - tid: tid, - args: item[:args], - ts: timestamp_in_microseconds(item[:start_time]), - } - - to_trace_event(item[:log], pid, tid, trace_events, item[:start_time]) - - trace_events << { - name: item[:name], - cat: "default", - ph: "E", # End event - pid: pid, - tid: tid, - ts: timestamp_in_microseconds(item[:end_time]), - } - else - trace_events << { - name: item[:name], - cat: "default", - ph: "B", - pid: pid, - tid: tid, - args: item[:args], - ts: timestamp_in_microseconds(item[:start_time] || parent_start_time || Time.now.utc), - s: "p", # Scope: process - } - trace_events << { - name: item[:name], - cat: "default", - ph: "E", - pid: pid, - tid: tid, - ts: timestamp_in_microseconds(item[:end_time] || Time.now.utc), - s: "p", - } - end - end - end - - def timestamp_in_microseconds(time) - (time.to_f * 1_000_000).to_i - end - end - - attr_reader :llms, :cases - - def self.evals_paths - @eval_paths ||= Dir.glob(File.join(File.join(__dir__, "../cases"), "*/*.yml")) - end - - def self.evals - @evals ||= evals_paths.map { |path| DiscourseAi::Evals::Eval.new(path: path) } - end - - def self.print - evals.each(&:print) - end - - def initialize(eval_name:, llms:) - @llms = llms - @eval = self.class.evals.find { |c| c.id == eval_name } - - if !@eval - puts "Error: Unknown evaluation '#{eval_name}'" - exit 1 - end - - if @llms.empty? - puts "Error: Unknown model 'model'" - exit 1 - end - end - - def run! - puts "Running evaluation '#{@eval.id}'" - - structured_log_filename = "#{@eval.id}-#{Time.now.strftime("%Y%m%d-%H%M%S")}.json" - log_filename = "#{@eval.id}-#{Time.now.strftime("%Y%m%d-%H%M%S")}.log" - logs_dir = File.join(__dir__, "../log") - FileUtils.mkdir_p(logs_dir) - - log_path = File.expand_path(File.join(logs_dir, log_filename)) - structured_log_path = File.expand_path(File.join(logs_dir, structured_log_filename)) - - logger = Logger.new(File.open(log_path, "a")) - logger.info("Starting evaluation '#{@eval.id}'") - - Thread.current[:llm_audit_log] = logger - structured_logger = Thread.current[:llm_audit_structured_log] = StructuredLogger.new - - structured_logger.step("Evaluating #{@eval.id}", args: @eval.to_json) do - llms.each do |llm| - if @eval.vision && !llm.vision? - logger.info("Skipping LLM: #{llm.name} as it does not support vision") - next - end - - structured_logger.step("Evaluating with LLM: #{llm.name}") do |step| - logger.info("Evaluating with LLM: #{llm.name}") - print "#{llm.name}: " - results = @eval.run(llm: llm) - - results.each do |result| - step[:args] = result - step[:cname] = result[:result] == :pass ? :good : :bad - - if result[:result] == :fail - puts "Failed 🔴" - puts "Error: #{result[:message]}" if result[:message] - # this is deliberate, it creates a lot of noise, but sometimes for debugging it's useful - #puts "Context: #{result[:context].to_s[0..2000]}" if result[:context] - if result[:expected_output] && result[:actual_output] - puts "---- Expected ----\n#{result[:expected_output]}" - puts "---- Actual ----\n#{result[:actual_output]}" - end - logger.error("Evaluation failed with LLM: #{llm.name}") - logger.error("Error: #{result[:message]}") if result[:message] - logger.error("Expected: #{result[:expected_output]}") if result[:expected_output] - logger.error("Actual: #{result[:actual_output]}") if result[:actual_output] - logger.error("Context: #{result[:context]}") if result[:context] - elsif result[:result] == :pass - puts "Passed 🟢" - logger.info("Evaluation passed with LLM: #{llm.name}") - else - STDERR.puts "Error: Unknown result #{eval.inspect}" - logger.error("Unknown result: #{eval.inspect}") - end - end - end - end - end - - #structured_logger.save(structured_log_path) - - File.write("#{structured_log_path}", structured_logger.to_trace_event_json) - - puts - puts "Log file: #{log_path}" - puts "Structured log file (ui.perfetto.dev): #{structured_log_path}" - - # temp code - # puts File.read(structured_log_path) - end -end diff --git a/evals/run b/evals/run deleted file mode 100755 index 8aa6c4ba..00000000 --- a/evals/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require_relative "lib/boot" -require_relative "lib/llm" -require_relative "lib/cli" -require_relative "lib/runner" -require_relative "lib/eval" -require_relative "lib/prompts/prompt_evaluator" -require_relative "lib/prompts/single_test_runner" - -options = DiscourseAi::Evals::Cli.parse_options! - -if options.list - DiscourseAi::Evals::Runner.print - exit 0 -end - -if options.list_models - DiscourseAi::Evals::Llm.print - exit 0 -end - -DiscourseAi::Evals::Runner.new( - eval_name: options.eval_name, - llms: DiscourseAi::Evals::Llm.choose(options.model), -).run! diff --git a/lib/ai_bot/chat_streamer.rb b/lib/ai_bot/chat_streamer.rb deleted file mode 100644 index 06357e0e..00000000 --- a/lib/ai_bot/chat_streamer.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true -# -# Chat streaming APIs are a bit slow, this ensures we properly buffer results -# and stream as quickly as possible. - -module DiscourseAi - module AiBot - class ChatStreamer - attr_reader :reply, - :guardian, - :thread_id, - :force_thread, - :in_reply_to_id, - :channel, - :cancel_manager - - def initialize( - message:, - channel:, - guardian:, - thread_id:, - in_reply_to_id:, - force_thread:, - cancel_manager: nil - ) - @message = message - @channel = channel - @guardian = guardian - @thread_id = thread_id - @force_thread = force_thread - @in_reply_to_id = in_reply_to_id - - @queue = Queue.new - - db = RailsMultisite::ConnectionManagement.current_db - @worker_thread = - Thread.new { RailsMultisite::ConnectionManagement.with_connection(db) { run } } - - @client_id = - ChatSDK::Channel.start_reply( - channel_id: message.chat_channel_id, - guardian: guardian, - thread_id: thread_id, - ) - - @cancel_manager = cancel_manager - end - - def <<(partial) - return if partial.to_s.empty? - # we throw away leading spaces prior to message creation for now - # by design - return if partial.to_s.blank? && !@reply - - if @client_id - ChatSDK::Channel.stop_reply( - channel_id: @message.chat_channel_id, - client_id: @client_id, - guardian: @guardian, - thread_id: @thread_id, - ) - @client_id = nil - end - - if @reply - @queue << partial - else - create_reply(partial) - end - end - - def create_reply(message) - @reply = - ChatSDK::Message.create( - raw: message, - channel_id: channel.id, - thread_id: thread_id, - guardian: guardian, - force_thread: force_thread, - in_reply_to_id: in_reply_to_id, - enforce_membership: !channel.direct_message_channel?, - ) - - ChatSDK::Message.start_stream(message_id: @reply.id, guardian: @guardian) - - if trailing = message.scan(/\s*\z/).first - @queue << trailing - end - end - - def done - @queue << :done - @worker_thread.join - ChatSDK::Message.stop_stream(message_id: @reply.id, guardian: @guardian) if @reply - @reply - end - - private - - def run - done = false - while !done - buffer = +"" - popped = @queue.pop - break if popped == :done - - buffer << popped - - begin - while true - popped = @queue.pop(true) - if popped == :done - done = true - break - end - buffer << popped - end - rescue ThreadError - end - - streaming = ChatSDK::Message.stream(message_id: reply.id, raw: buffer, guardian: guardian) - if !streaming - @cancel_manager.cancel! if @cancel_manager - end - end - end - end - end -end diff --git a/lib/ai_bot/entry_point.rb b/lib/ai_bot/entry_point.rb deleted file mode 100644 index 7bcdc646..00000000 --- a/lib/ai_bot/entry_point.rb +++ /dev/null @@ -1,294 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - USER_AGENT = "Discourse AI Bot 1.0 (https://www.discourse.org)" - TOPIC_AI_BOT_PM_FIELD = "is_ai_bot_pm" - POST_AI_LLM_NAME_FIELD = "ai_llm_name" - - class EntryPoint - Bot = Struct.new(:id, :name, :llm) - - def self.all_bot_ids - AiPersona - .persona_users - .map { |persona| persona[:user_id] } - .concat(LlmModel.where(enabled_chat_bot: true).pluck(:user_id)) - end - - def self.find_participant_in(participant_ids) - model = LlmModel.includes(:user).where(user_id: participant_ids).last - return if model.nil? - - bot_user = model.user - - Bot.new(bot_user.id, bot_user.username_lower, model.name) - end - - def self.find_user_from_model(model_name) - # Hack(Roman): Added this because Command R Plus had a different in the bot settings. - # Will eventually ammend it with a data migration. - name = model_name - name = "command-r-plus" if name == "cohere-command-r-plus" - - LlmModel.joins(:user).where(name: name).last&.user - end - - def self.enabled_user_ids_and_models_map - DB.query_hash(<<~SQL) - SELECT users.username AS username, users.id AS id, llms.name AS model_name, llms.display_name AS display_name - FROM llm_models llms - INNER JOIN users ON llms.user_id = users.id - WHERE llms.enabled_chat_bot - SQL - end - - # Most errors are simply "not_allowed" - # we do not want to reveal information about this system - # the 2 exceptions are "other_people_in_pm" and "other_content_in_pm" - # in both cases you have access to the PM so we are not revealing anything - def self.ai_share_error(topic, guardian) - return nil if guardian.can_share_ai_bot_conversation?(topic) - - return :not_allowed if !guardian.can_see?(topic) - - # other people in PM - if topic.topic_allowed_users.where("user_id > 0 and user_id <> ?", guardian.user.id).exists? - return :other_people_in_pm - end - - # other content in PM - if topic.posts.where("user_id > 0 and user_id <> ?", guardian.user.id).exists? - return :other_content_in_pm - end - - :not_allowed - end - - def inject_into(plugin) - # Long term we need a better API here - # we only want to load this custom field for bots - TopicView.default_post_custom_fields << POST_AI_LLM_NAME_FIELD - - plugin.register_topic_custom_field_type(TOPIC_AI_BOT_PM_FIELD, :string) - - plugin.on(:topic_created) do |topic| - next if !topic.private_message? - creator = topic.user - - # Only process if creator is not a bot or system user - next if DiscourseAi::AiBot::Playground.is_bot_user_id?(creator.id) - - # Get all bot user IDs defined by the discourse-ai plugin - bot_ids = DiscourseAi::AiBot::EntryPoint.all_bot_ids - - # Check if the only recipients are bots - recipients = topic.topic_allowed_users.pluck(:user_id) - - # Remove creator from recipients for checking - recipients -= [creator.id] - - # If all remaining recipients are AI bots and there's exactly one recipient - if recipients.length == 1 && (recipients - bot_ids).empty? - # The only recipient is an AI bot - add the custom field to the topic - topic.custom_fields[TOPIC_AI_BOT_PM_FIELD] = true - - # Save the custom fields - topic.save_custom_fields - end - end - - plugin.register_modifier(:chat_allowed_bot_user_ids) do |user_ids, guardian| - if guardian.user - allowed_chat = - AiPersona.allowed_modalities( - user: guardian.user, - allow_chat_direct_messages: true, - allow_chat_channel_mentions: true, - ) - allowed_bot_ids = allowed_chat.map { |info| info[:user_id] } - user_ids.concat(allowed_bot_ids) - end - user_ids - end - - plugin.on(:site_setting_changed) do |name, _old_value, _new_value| - if name == :ai_bot_enabled || name == :discourse_ai_enabled - DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots - end - end - - Oneboxer.register_local_handler( - "discourse_ai/ai_bot/shared_ai_conversations", - ) do |url, route| - if route[:action] == "show" && share_key = route[:share_key] - if conversation = SharedAiConversation.find_by(share_key: share_key) - conversation.onebox - end - end - end - - plugin.on(:reduce_excerpt) do |doc, options| - doc.css("details").remove if options && options[:strip_details] - end - - plugin.register_seedfu_fixtures( - Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"), - ) - - plugin.add_to_serializer( - :topic_view, - :is_bot_pm, - include_condition: -> do - object.topic && object.personal_message && - object.topic.custom_fields[TOPIC_AI_BOT_PM_FIELD] - end, - ) { true } - - plugin.add_to_serializer( - :post, - :llm_name, - include_condition: -> do - object&.topic&.private_message? && object.custom_fields[POST_AI_LLM_NAME_FIELD] - end, - ) { object.custom_fields[POST_AI_LLM_NAME_FIELD] } - - plugin.add_to_serializer( - :current_user, - :ai_enabled_personas, - include_condition: -> { scope.authenticated? }, - ) do - DiscourseAi::Personas::Persona - .all(user: scope.user) - .map do |persona| - { - id: persona.id, - name: persona.name, - description: persona.description, - force_default_llm: persona.force_default_llm, - username: persona.username, - } - end - end - - plugin.add_to_serializer( - :current_user, - :can_debug_ai_bot_conversations, - include_condition: -> do - SiteSetting.ai_bot_enabled && scope.authenticated? && - SiteSetting.ai_bot_debugging_allowed_groups.present? && - scope.user.in_any_groups?(SiteSetting.ai_bot_debugging_allowed_groups_map) - end, - ) { true } - - plugin.add_to_serializer( - :current_user, - :ai_enabled_chat_bots, - include_condition: -> do - SiteSetting.ai_bot_enabled && scope.authenticated? && - scope.user.in_any_groups?(SiteSetting.ai_bot_allowed_groups_map) - end, - ) do - bots_map = ::DiscourseAi::AiBot::EntryPoint.enabled_user_ids_and_models_map - - persona_users = AiPersona.persona_users(user: scope.user) - if persona_users.present? - persona_users.filter! { |persona_user| persona_user[:username].present? } - - bots_map.concat( - persona_users.map do |persona_user| - { - "id" => persona_user[:user_id], - "username" => persona_user[:username], - "has_default_llm" => persona_user[:default_llm_id].present?, - "force_default_llm" => persona_user[:force_default_llm], - "is_persona" => true, - } - end, - ) - end - - bots_map - end - - plugin.add_to_serializer(:current_user, :can_share_ai_bot_conversations) do - scope.user.in_any_groups?(SiteSetting.ai_bot_public_sharing_allowed_groups_map) - end - - plugin.add_to_serializer( - :current_user, - :can_use_ai_bot_discover_persona, - include_condition: -> do - SiteSetting.ai_bot_enabled && scope.authenticated? && - SiteSetting.ai_bot_discover_persona.present? - end, - ) do - persona_allowed_groups = - AiPersona.find_by(id: SiteSetting.ai_bot_discover_persona)&.allowed_group_ids.to_a - - scope.user.in_any_groups?(persona_allowed_groups) - end - - UserUpdater::OPTION_ATTR.push(:ai_search_discoveries) - plugin.add_to_serializer( - :user_option, - :ai_search_discoveries, - include_condition: -> do - SiteSetting.ai_bot_enabled && SiteSetting.ai_bot_discover_persona.present? && - scope.authenticated? - end, - ) { object.ai_search_discoveries } - - plugin.add_to_serializer( - :current_user_option, - :ai_search_discoveries, - include_condition: -> do - SiteSetting.ai_bot_enabled && SiteSetting.ai_bot_discover_persona.present? && - scope.authenticated? - end, - ) { object.ai_search_discoveries } - - plugin.add_to_serializer( - :topic_view, - :ai_persona_name, - include_condition: -> { SiteSetting.ai_bot_enabled && object.topic.private_message? }, - ) do - id = topic.custom_fields["ai_persona_id"] - name = DiscourseAi::Personas::Persona.find_by(user: scope.user, id: id.to_i)&.name if id - name || topic.custom_fields["ai_persona"] - end - - plugin.on(:post_created) { |post| DiscourseAi::AiBot::Playground.schedule_reply(post) } - - plugin.on(:chat_message_created) do |chat_message, channel, user, context| - DiscourseAi::AiBot::Playground.schedule_chat_reply(chat_message, channel, user, context) - end - - if plugin.respond_to?(:register_editable_topic_custom_field) - plugin.register_editable_topic_custom_field(:ai_persona_id) - end - - plugin.add_api_key_scope( - :discourse_ai, - { stream_completion: { actions: %w[discourse_ai/admin/ai_personas#stream_reply] } }, - ) - - plugin.on(:site_setting_changed) do |name, old_value, new_value| - if name == :ai_embeddings_selected_model && DiscourseAi::Embeddings.enabled? && - new_value != old_value - RagDocumentFragment.delete_all - UploadReference - .where(target: AiPersona.all) - .each do |ref| - Jobs.enqueue( - :digest_rag_upload, - ai_persona_id: ref.target_id, - upload_id: ref.upload_id, - ) - end - end - end - end - end - end -end diff --git a/lib/ai_bot/playground.rb b/lib/ai_bot/playground.rb deleted file mode 100644 index 07c4984b..00000000 --- a/lib/ai_bot/playground.rb +++ /dev/null @@ -1,661 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class Playground - BYPASS_AI_REPLY_CUSTOM_FIELD = "discourse_ai_bypass_ai_reply" - BOT_USER_PREF_ID_CUSTOM_FIELD = "discourse_ai_bot_user_pref_id" - # 10 minutes is enough for vast majority of cases - # there is a small chance that some reasoning models may take longer - MAX_STREAM_DELAY_SECONDS = 600 - - attr_reader :bot - - # An abstraction to manage the bot and topic interactions. - # The bot will take care of completions while this class updates the topic title - # and stream replies. - - def self.find_chat_persona(message, channel, user) - if channel.direct_message_channel? - AiPersona - .allowed_modalities(allow_chat_direct_messages: true) - .find do |p| - p[:user_id].in?(channel.allowed_user_ids) && (user.group_ids & p[:allowed_group_ids]) - end - else - # let's defer on the parse if there is no @ in the message - if message.message.include?("@") - mentions = message.parsed_mentions.parsed_direct_mentions - if mentions.present? - AiPersona - .allowed_modalities(allow_chat_channel_mentions: true) - .find { |p| p[:username].in?(mentions) && (user.group_ids & p[:allowed_group_ids]) } - end - end - end - end - - def self.schedule_chat_reply(message, channel, user, context) - return if !SiteSetting.ai_bot_enabled - - all_chat = - AiPersona.allowed_modalities( - allow_chat_channel_mentions: true, - allow_chat_direct_messages: true, - ) - return if all_chat.blank? - return if all_chat.any? { |m| m[:user_id] == user.id } - - persona = find_chat_persona(message, channel, user) - return if !persona - - post_ids = nil - post_ids = context.dig(:context, :post_ids) if context.is_a?(Hash) - - ::Jobs.enqueue( - :create_ai_chat_reply, - channel_id: channel.id, - message_id: message.id, - persona_id: persona[:id], - context_post_ids: post_ids, - ) - end - - def self.is_bot_user_id?(user_id) - # this will catch everything and avoid any feedback loops - # we could get feedback loops between say discobot and ai-bot or third party plugins - # and bots - user_id.to_i <= 0 - end - - def self.get_bot_user(post:, all_llm_users:, mentionables:) - bot_user = nil - if post.topic.private_message? - # this ensures that we reply using the correct llm - # 1. if we have a preferred llm user we use that - # 2. if we don't just take first topic allowed user - # 3. if we don't have that we take the first mentionable - bot_user = nil - if preferred_user = - all_llm_users.find { |id, username| - id == post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD].to_i - } - bot_user = User.find_by(id: preferred_user[0]) - end - bot_user ||= - post.topic.topic_allowed_users.where(user_id: all_llm_users.map(&:first)).first&.user - bot_user ||= - post - .topic - .topic_allowed_users - .where(user_id: mentionables.map { |m| m[:user_id] }) - .first - &.user - end - bot_user - end - - def self.schedule_reply(post) - return if is_bot_user_id?(post.user_id) - mentionables = nil - - if post.topic.private_message? - mentionables = - AiPersona.allowed_modalities(user: post.user, allow_personal_messages: true) - else - mentionables = AiPersona.allowed_modalities(user: post.user, allow_topic_mentions: true) - end - - mentioned = nil - - all_llm_users = - LlmModel - .where(enabled_chat_bot: true) - .joins(:user) - .pluck("users.id", "users.username_lower") - - bot_user = - get_bot_user(post: post, all_llm_users: all_llm_users, mentionables: mentionables) - - mentions = nil - if mentionables.present? || (bot_user && post.topic.private_message?) - mentions = post.mentions.map(&:downcase) - - # in case we are replying to a post by a bot - if post.reply_to_post_number && post.reply_to_post&.user - mentions << post.reply_to_post.user.username_lower - end - end - - if mentionables.present? - mentioned = mentionables.find { |mentionable| mentions.include?(mentionable[:username]) } - - # direct PM to mentionable - if !mentioned && bot_user - mentioned = mentionables.find { |mentionable| bot_user.id == mentionable[:user_id] } - end - - # public topic so we need to use the persona user - bot_user ||= User.find_by(id: mentioned[:user_id]) if mentioned - end - - if !mentioned && bot_user && post.reply_to_post_number && !post.reply_to_post.user&.bot? - # replying to a non-bot user - return - end - - if bot_user - topic_persona_id = post.topic.custom_fields["ai_persona_id"] - topic_persona_id = topic_persona_id.to_i if topic_persona_id.present? - - persona_id = mentioned&.dig(:id) || topic_persona_id - - persona = nil - - if persona_id - persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id.to_i) - end - - if !persona && persona_name = post.topic.custom_fields["ai_persona"] - persona = DiscourseAi::Personas::Persona.find_by(user: post.user, name: persona_name) - end - - # edge case, llm was mentioned in an ai persona conversation - if persona_id == topic_persona_id && post.topic.private_message? && persona && - all_llm_users.present? - if !persona.force_default_llm && mentions.present? - mentioned_llm_user_id, _ = - all_llm_users.find { |id, username| mentions.include?(username) } - - if mentioned_llm_user_id - bot_user = User.find_by(id: mentioned_llm_user_id) || bot_user - end - end - end - - persona ||= DiscourseAi::Personas::General - - bot_user = User.find(persona.user_id) if persona && persona.force_default_llm - - bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new) - new(bot).update_playground_with(post) - end - end - - def self.reply_to_post( - post:, - user: nil, - persona_id: nil, - whisper: nil, - add_user_to_pm: false, - stream_reply: false, - auto_set_title: false, - silent_mode: false, - feature_name: nil - ) - ai_persona = AiPersona.find_by(id: persona_id) - raise Discourse::InvalidParameters.new(:persona_id) if !ai_persona - persona_class = ai_persona.class_instance - persona = persona_class.new - - bot_user = user || ai_persona.user - raise Discourse::InvalidParameters.new(:user) if bot_user.nil? - bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona) - playground = new(bot) - - playground.reply_to( - post, - whisper: whisper, - context_style: :topic, - add_user_to_pm: add_user_to_pm, - stream_reply: stream_reply, - auto_set_title: auto_set_title, - silent_mode: silent_mode, - feature_name: feature_name, - ) - rescue => e - if Rails.env.test? - p e - puts e.backtrace[0..10] - else - raise e - end - end - - def initialize(bot) - @bot = bot - end - - def update_playground_with(post) - schedule_bot_reply(post) if can_attach?(post) - end - - def title_playground(post, user) - messages = - DiscourseAi::Completions::PromptMessagesBuilder.messages_from_post( - post, - max_posts: 5, - bot_usernames: available_bot_usernames, - include_uploads: bot.persona.class.vision_enabled, - ) - - # conversation context may contain tool calls, and confusing user names - # clean it up - conversation = +"" - messages.each do |context| - if context[:type] == :user - conversation << "User said:\n#{context[:content]}\n\n" - elsif context[:type] == :model - conversation << "Model said:\n#{context[:content]}\n\n" - end - end - - system_insts = <<~TEXT.strip - You are titlebot. Given a conversation, you will suggest a title. - - - You will never respond with anything but the suggested title. - - You will always match the conversation language in your title suggestion. - - Title will capture the essence of the conversation. - TEXT - - instruction = <<~TEXT.strip - Given the following conversation: - - {{{ - #{conversation} - }}} - - Reply only with a title that is 7 words or less. - TEXT - - title_prompt = - DiscourseAi::Completions::Prompt.new( - system_insts, - messages: [type: :user, content: instruction], - topic_id: post.topic_id, - ) - - new_title = - bot - .llm - .generate(title_prompt, user: user, feature_name: "bot_title") - .strip - .split("\n") - .last - - PostRevisor.new(post.topic.first_post, post.topic).revise!( - bot.bot_user, - title: new_title.sub(/\A"/, "").sub(/"\Z/, ""), - ) - - allowed_users = post.topic.topic_allowed_users.pluck(:user_id) - MessageBus.publish( - "/discourse-ai/ai-bot/topic/#{post.topic.id}", - { title: post.topic.title }, - user_ids: allowed_users, - ) - end - - def reply_to_chat_message(message, channel, context_post_ids) - persona_user = User.find(bot.persona.class.user_id) - - participants = channel.user_chat_channel_memberships.map { |m| m.user.username } - - context_post_ids = nil if !channel.direct_message_channel? - - max_chat_messages = 40 - if bot.persona.class.respond_to?(:max_context_posts) - max_chat_messages = bot.persona.class.max_context_posts || 40 - end - - if !channel.direct_message_channel? - # we are interacting via mentions ... strip mention - instruction_message = message.message.gsub(/@#{bot.bot_user.username}/i, "").strip - end - - context = - DiscourseAi::Personas::BotContext.new( - participants: participants, - message_id: message.id, - channel_id: channel.id, - context_post_ids: context_post_ids, - messages: - DiscourseAi::Completions::PromptMessagesBuilder.messages_from_chat( - message, - channel: channel, - context_post_ids: context_post_ids, - include_uploads: bot.persona.class.vision_enabled, - max_messages: max_chat_messages, - bot_user_ids: available_bot_user_ids, - instruction_message: instruction_message, - ), - user: message.user, - skip_tool_details: true, - cancel_manager: DiscourseAi::Completions::CancelManager.new, - ) - - reply = nil - guardian = Guardian.new(persona_user) - - force_thread = message.thread_id.nil? && channel.direct_message_channel? - in_reply_to_id = channel.direct_message_channel? ? message.id : nil - - streamer = - ChatStreamer.new( - message: message, - channel: channel, - guardian: guardian, - thread_id: message.thread_id, - in_reply_to_id: in_reply_to_id, - force_thread: force_thread, - cancel_manager: context.cancel_manager, - ) - - new_prompts = - bot.reply(context) do |partial, placeholder, type| - # no support for tools or thinking by design - next if type == :thinking || type == :tool_details || type == :partial_tool - streamer << partial - end - - reply = streamer.reply - if new_prompts.length > 1 && reply - ChatMessageCustomPrompt.create!(message_id: reply.id, custom_prompt: new_prompts) - end - - if streamer - streamer.done - streamer = nil - end - - reply - ensure - streamer.done if streamer - end - - def reply_to( - post, - custom_instructions: nil, - whisper: nil, - context_style: nil, - add_user_to_pm: true, - stream_reply: nil, - auto_set_title: true, - silent_mode: false, - feature_name: nil, - cancel_manager: nil, - &blk - ) - # this is a multithreading issue - # post custom prompt is needed and it may not - # be properly loaded, ensure it is loaded - PostCustomPrompt.none - - if silent_mode - auto_set_title = false - stream_reply = false - end - - reply = +"" - post_streamer = nil - - post_type = - ( - if (whisper || post.post_type == Post.types[:whisper]) - Post.types[:whisper] - else - Post.types[:regular] - end - ) - - # safeguard - max_context_posts = 40 - if bot.persona.class.respond_to?(:max_context_posts) - max_context_posts = bot.persona.class.max_context_posts || 40 - end - - context = - DiscourseAi::Personas::BotContext.new( - post: post, - custom_instructions: custom_instructions, - feature_name: feature_name, - messages: - DiscourseAi::Completions::PromptMessagesBuilder.messages_from_post( - post, - style: context_style, - max_posts: max_context_posts, - include_uploads: bot.persona.class.vision_enabled, - bot_usernames: available_bot_usernames, - ), - ) - - reply_user = bot.bot_user - if bot.persona.class.respond_to?(:user_id) - reply_user = User.find_by(id: bot.persona.class.user_id) || reply_user - end - - stream_reply = post.topic.private_message? if stream_reply.nil? - - # we need to ensure persona user is allowed to reply to the pm - if post.topic.private_message? && add_user_to_pm - if !post.topic.topic_allowed_users.exists?(user_id: reply_user.id) - post.topic.topic_allowed_users.create!(user_id: reply_user.id) - end - # edge case, maybe the llm user is missing? - if !post.topic.topic_allowed_users.exists?(user_id: bot.bot_user.id) - post.topic.topic_allowed_users.create!(user_id: bot.bot_user.id) - end - - # we store the id of the last bot_user, this is then used to give it preference - if post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD].to_i != bot.bot_user.id - post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD] = bot.bot_user.id - post.topic.save_custom_fields - end - end - - if stream_reply - reply_post = - PostCreator.create!( - reply_user, - topic_id: post.topic_id, - raw: "", - skip_validations: true, - skip_jobs: true, - post_type: post_type, - skip_guardian: true, - custom_fields: { - DiscourseAi::AiBot::POST_AI_LLM_NAME_FIELD => bot.llm.llm_model.display_name, - }, - ) - - publish_update(reply_post, { raw: reply_post.cooked }) - - redis_stream_key = "gpt_cancel:#{reply_post.id}" - Discourse.redis.setex(redis_stream_key, MAX_STREAM_DELAY_SECONDS, 1) - - cancel_manager ||= DiscourseAi::Completions::CancelManager.new - context.cancel_manager = cancel_manager - context - .cancel_manager - .start_monitor(delay: 0.2) do - context.cancel_manager.cancel! if !Discourse.redis.get(redis_stream_key) - end - - context.cancel_manager.add_callback( - lambda { reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) }, - ) - end - - context.skip_tool_details ||= !bot.persona.class.tool_details - post_streamer = PostStreamer.new(delay: Rails.env.test? ? 0 : 0.5) if stream_reply - started_thinking = false - - new_custom_prompts = - bot.reply(context) do |partial, placeholder, type| - if type == :thinking && !started_thinking - reply << "
#{I18n.t("discourse_ai.ai_bot.thinking")}" - started_thinking = true - end - - if type != :thinking && started_thinking - reply << "
\n\n" - started_thinking = false - end - - reply << partial - raw = reply.dup - raw << "\n\n" << placeholder if placeholder.present? - - if blk && type != :tool_details && type != :partial_tool && type != :partial_invoke - blk.call(partial) - end - - if post_streamer - post_streamer.run_later do - Discourse.redis.expire(redis_stream_key, MAX_STREAM_DELAY_SECONDS) - publish_update(reply_post, { raw: raw }) - end - end - end - - return if reply.blank? || silent_mode - - if stream_reply - post_streamer.finish - post_streamer = nil - - # land the final message prior to saving so we don't clash - reply_post.cooked = PrettyText.cook(reply) - publish_final_update(reply_post) - - reply_post.revise( - bot.bot_user, - { raw: reply }, - skip_validations: true, - skip_revision: true, - ) - else - reply_post = - PostCreator.create!( - reply_user, - topic_id: post.topic_id, - raw: reply, - skip_validations: true, - post_type: post_type, - skip_guardian: true, - ) - end - - # a bit messy internally, but this is how we tell - is_thinking = new_custom_prompts.any? { |prompt| prompt[4].present? } - - if is_thinking || new_custom_prompts.length > 1 - reply_post.post_custom_prompt ||= reply_post.build_post_custom_prompt(custom_prompt: []) - prompt = reply_post.post_custom_prompt.custom_prompt || [] - prompt.concat(new_custom_prompts) - reply_post.post_custom_prompt.update!(custom_prompt: prompt) - end - - reply_post - rescue => e - if reply_post - details = e.message.to_s - reply = "#{reply}\n\n#{I18n.t("discourse_ai.ai_bot.reply_error", details: details)}" - reply_post.revise( - bot.bot_user, - { raw: reply }, - skip_validations: true, - skip_revision: true, - ) - end - raise e - ensure - context.cancel_manager.stop_monitor if context&.cancel_manager - - # since we are skipping validations and jobs we - # may need to fix participant count - if reply_post && reply_post.topic && reply_post.topic.private_message? && - reply_post.topic.participant_count < 2 - reply_post.topic.update!(participant_count: 2) - end - post_streamer&.finish(skip_callback: true) - publish_final_update(reply_post) if stream_reply - if reply_post && post.post_number == 1 && post.topic.private_message? && auto_set_title - title_playground(reply_post, post.user) - end - end - - def available_bot_usernames - @bot_usernames ||= - AiPersona.joins(:user).pluck(:username).concat(available_bot_users.map(&:username)) - end - - def available_bot_user_ids - @bot_ids ||= AiPersona.joins(:user).pluck("users.id").concat(available_bot_users.map(&:id)) - end - - private - - def available_bot_users - @available_bots ||= - User.joins("INNER JOIN llm_models llm ON llm.user_id = users.id").where(active: true) - end - - def publish_final_update(reply_post) - return if @published_final_update - if reply_post - publish_update(reply_post, { cooked: reply_post.cooked, done: true }) - # we subscribe at position -2 so we will always get this message - # moving all cooked on every page load is wasteful ... this means - # we have a benign message at the end, 2 is set to ensure last message - # is delivered - publish_update(reply_post, { noop: true }) - @published_final_update = true - end - end - - def can_attach?(post) - return false if bot.bot_user.nil? - return false if post.topic.private_message? && post.post_type != Post.types[:regular] - return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank? - return false if post.custom_fields[BYPASS_AI_REPLY_CUSTOM_FIELD].present? - - true - end - - def schedule_bot_reply(post) - persona_id = - DiscourseAi::Personas::Persona.system_personas[bot.persona.class] || bot.persona.class.id - ::Jobs.enqueue( - :create_ai_reply, - post_id: post.id, - bot_user_id: bot.bot_user.id, - persona_id: persona_id, - ) - end - - def context(topic) - { - site_url: Discourse.base_url, - site_title: SiteSetting.title, - site_description: SiteSetting.site_description, - time: Time.zone.now, - participants: topic.allowed_users.map(&:username).join(", "), - } - end - - def publish_update(bot_reply_post, payload) - payload = { post_id: bot_reply_post.id, post_number: bot_reply_post.post_number }.merge( - payload, - ) - MessageBus.publish( - "discourse-ai/ai-bot/topic/#{bot_reply_post.topic_id}", - payload, - user_ids: bot_reply_post.topic.allowed_user_ids, - max_backlog_size: 2, - max_backlog_age: MAX_STREAM_DELAY_SECONDS, - ) - end - end - end -end diff --git a/lib/ai_bot/post_streamer.rb b/lib/ai_bot/post_streamer.rb deleted file mode 100644 index 73621a2f..00000000 --- a/lib/ai_bot/post_streamer.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class PostStreamer - # test only - def self.on_callback=(on_callback) - @on_callback = on_callback - end - - def self.on_callback - @on_callback - end - - def initialize(delay: 0.5) - @mutex = Mutex.new - @callback = nil - @delay = delay - @done = false - end - - def run_later(&callback) - self.class.on_callback.call(callback) if self.class.on_callback - @mutex.synchronize { @callback = callback } - ensure_worker! - end - - def finish(skip_callback: false) - @mutex.synchronize do - @callback&.call if skip_callback - @callback = nil - @done = true - end - - begin - @worker_thread&.wakeup - rescue StandardError - ThreadError - end - @worker_thread&.join - @worker_thread = nil - end - - private - - def run - while !@done - @mutex.synchronize do - callback = @callback - @callback = nil - callback&.call - end - sleep @delay - end - end - - def ensure_worker! - return if @worker_thread - @mutex.synchronize do - return if @worker_thread - db = RailsMultisite::ConnectionManagement.current_db - @worker_thread = - Thread.new { RailsMultisite::ConnectionManagement.with_connection(db) { run } } - end - end - end - end -end diff --git a/lib/ai_bot/response_http_streamer.rb b/lib/ai_bot/response_http_streamer.rb deleted file mode 100644 index cfb2fbeb..00000000 --- a/lib/ai_bot/response_http_streamer.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiBot - class ResponseHttpStreamer - CRLF = "\r\n" - POOL_SIZE = 10 - - class << self - def thread_pool - # we use our thread pool implementation here for a few reasons: - # - # 1. Free multisite support - # 2. Unlike Concurrent::CachedThreadPool, we spin back down to 0 threads automatiaclly see: https://github.com/ruby-concurrency/concurrent-ruby/issues/1075 - # 3. Better internal error handling - @thread_pool ||= - Scheduler::ThreadPool.new(min_threads: 0, max_threads: POOL_SIZE, idle_time: 30) - end - - def schedule_block(&block) - thread_pool.post do - begin - block.call - rescue StandardError => e - Discourse.warn_exception(e, message: "Discourse AI: Unable to stream reply") - end - end - end - - # keeping this in a static method so we don't capture ENV and other bits - # this allows us to release memory earlier - def queue_streamed_reply( - io:, - persona:, - user:, - topic:, - query:, - custom_instructions:, - current_user: - ) - schedule_block do - begin - post_params = { - raw: query, - skip_validations: true, - custom_fields: { - DiscourseAi::AiBot::Playground::BYPASS_AI_REPLY_CUSTOM_FIELD => true, - }, - } - - if topic - post_params[:topic_id] = topic.id - else - post_params[:title] = I18n.t("discourse_ai.ai_bot.default_pm_prefix") - post_params[:archetype] = Archetype.private_message - post_params[:target_usernames] = "#{user.username},#{persona.user.username}" - end - - post = PostCreator.create!(user, post_params) - topic = post.topic - - io.write "HTTP/1.1 200 OK" - io.write CRLF - io.write "Content-Type: text/plain; charset=utf-8" - io.write CRLF - io.write "Transfer-Encoding: chunked" - io.write CRLF - io.write "Cache-Control: no-cache, no-store, must-revalidate" - io.write CRLF - io.write "Connection: close" - io.write CRLF - io.write "X-Accel-Buffering: no" - io.write CRLF - io.write "X-Content-Type-Options: nosniff" - io.write CRLF - io.write CRLF - io.flush - - persona_class = - DiscourseAi::Personas::Persona.find_by(id: persona.id, user: current_user) - bot = DiscourseAi::Personas::Bot.as(persona.user, persona: persona_class.new) - - data = - { - topic_id: topic.id, - bot_user_id: persona.user.id, - persona_id: persona.id, - }.to_json + "\n\n" - - io.write data.bytesize.to_s(16) - io.write CRLF - io.write data - io.write CRLF - - DiscourseAi::AiBot::Playground - .new(bot) - .reply_to(post, custom_instructions: custom_instructions) do |partial| - next if partial.length == 0 - - data = { partial: partial }.to_json + "\n\n" - - data.force_encoding("UTF-8") - - io.write data.bytesize.to_s(16) - io.write CRLF - io.write data - io.write CRLF - io.flush - end - - io.write "0" - io.write CRLF - io.write CRLF - - io.flush - io.done if io.respond_to?(:done) - rescue StandardError => e - # make it a tiny bit easier to debug in dev, this is tricky - # multi-threaded code that exhibits various limitations in rails - p e if Rails.env.development? || Rails.env.test? - Discourse.warn_exception(e, message: "Discourse AI: Unable to stream reply") - ensure - io.close - end - end - end - end - end - end -end diff --git a/lib/ai_bot/site_settings_extension.rb b/lib/ai_bot/site_settings_extension.rb deleted file mode 100644 index 4aa3653c..00000000 --- a/lib/ai_bot/site_settings_extension.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi::AiBot::SiteSettingsExtension - def self.enable_or_disable_ai_bots - LlmModel.find_each { |llm_model| llm_model.toggle_companion_user } - end -end diff --git a/lib/ai_helper/assistant.rb b/lib/ai_helper/assistant.rb deleted file mode 100644 index be61e415..00000000 --- a/lib/ai_helper/assistant.rb +++ /dev/null @@ -1,469 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiHelper - class Assistant - IMAGE_CAPTION_MAX_WORDS = 50 - - TRANSLATE = "translate" - GENERATE_TITLES = "generate_titles" - PROOFREAD = "proofread" - MARKDOWN_TABLE = "markdown_table" - CUSTOM_PROMPT = "custom_prompt" - EXPLAIN = "explain" - ILLUSTRATE_POST = "illustrate_post" - REPLACE_DATES = "replace_dates" - IMAGE_CAPTION = "image_caption" - - def self.prompt_cache - @prompt_cache ||= ::DiscourseAi::MultisiteHash.new("prompt_cache") - end - - def self.clear_prompt_cache! - prompt_cache.flush! - end - - def initialize(helper_llm: nil, image_caption_llm: nil) - @helper_llm = helper_llm - @image_caption_llm = image_caption_llm - end - - def available_prompts(user) - key = "prompt_cache_#{I18n.locale}" - prompts = self.class.prompt_cache.fetch(key) { self.all_prompts } - - prompts - .map do |prompt| - next if !user.in_any_groups?(prompt[:allowed_group_ids]) - - if prompt[:name] == ILLUSTRATE_POST && - SiteSetting.ai_helper_illustrate_post_model == "disabled" - next - end - - # We cannot cache this. It depends on the user's effective_locale. - if prompt[:name] == TRANSLATE - locale = user.effective_locale - locale_hash = - LocaleSiteSetting.language_names[locale] || - LocaleSiteSetting.language_names[locale.split("_")[0]] - translation = - I18n.t( - "discourse_ai.ai_helper.prompts.translate", - language: locale_hash["nativeName"], - ) || prompt[:name] - - prompt.merge(translated_name: translation) - else - prompt - end - end - .compact - end - - def custom_locale_instructions(user = nil, force_default_locale) - locale = SiteSetting.default_locale - locale = user.effective_locale if !force_default_locale && user - locale_hash = LocaleSiteSetting.language_names[locale] - - if locale != "en" && locale_hash - locale_description = "#{locale_hash["name"]} (#{locale_hash["nativeName"]})" - "It is imperative that you write your answer in #{locale_description}, you are interacting with a #{locale_description} speaking user. Leave tag names in English." - else - nil - end - end - - def attach_user_context(context, user = nil, force_default_locale: false) - locale = SiteSetting.default_locale - locale = user.effective_locale if user && !force_default_locale - locale_hash = LocaleSiteSetting.language_names[locale] - - context.user_language = "#{locale_hash["name"]}" - - if user - timezone = user&.user_option&.timezone || "UTC" - current_time = Time.now.in_time_zone(timezone) - - temporal_context = { - utc_date_time: current_time.iso8601, - local_time: current_time.strftime("%H:%M"), - user: { - timezone: timezone, - weekday: current_time.strftime("%A"), - }, - } - - context.temporal_context = temporal_context.to_json - end - - context - end - - def generate_prompt( - helper_mode, - input, - user, - force_default_locale: false, - custom_prompt: nil, - &block - ) - bot = build_bot(helper_mode, user) - - user_input = "#{input}" - if helper_mode == CUSTOM_PROMPT && custom_prompt.present? - user_input = "#{custom_prompt}:\n#{input}" - end - - context = - DiscourseAi::Personas::BotContext.new( - user: user, - skip_tool_details: true, - feature_name: "ai_helper", - messages: [{ type: :user, content: user_input }], - format_dates: helper_mode == REPLACE_DATES, - custom_instructions: custom_locale_instructions(user, force_default_locale), - ) - context = attach_user_context(context, user, force_default_locale: force_default_locale) - - bad_json = false - json_summary_schema_key = bot.persona.response_format&.first.to_h - - schema_key = json_summary_schema_key["key"]&.to_sym - schema_type = json_summary_schema_key["type"] - - if schema_type == "array" - helper_response = [] - else - helper_response = +"" - end - - buffer_blk = - Proc.new do |partial, _, type| - if type == :structured_output && schema_type - helper_chunk = partial.read_buffered_property(schema_key) - next if helper_chunk.nil? || helper_chunk.empty? - - if schema_type == "array" - if helper_chunk.is_a?(Array) - helper_chunk.each do |item| - helper_response << item if helper_response.exclude?(item) - end - end - elsif schema_type == "string" - helper_response << helper_chunk - else - helper_response = helper_chunk - end - - block.call(helper_chunk) if block && !bad_json - elsif type.blank? - # Assume response is a regular completion. - helper_response << partial - block.call(partial) if block - end - end - - bot.reply(context, &buffer_blk) - - helper_response - end - - def generate_and_send_prompt( - helper_mode, - input, - user, - force_default_locale: false, - custom_prompt: nil - ) - helper_response = - generate_prompt( - helper_mode, - input, - user, - force_default_locale: force_default_locale, - custom_prompt: custom_prompt, - ) - result = { type: prompt_type(helper_mode) } - - result[:suggestions] = ( - if result[:type] == :list - helper_response.flatten.map { |suggestion| sanitize_result(suggestion) } - else - sanitized = sanitize_result(helper_response) - result[:diff] = parse_diff(input, sanitized) if result[:type] == :diff - [sanitized] - end - ) - - result - end - - def stream_prompt( - helper_mode, - input, - user, - channel, - force_default_locale: false, - client_id: nil, - custom_prompt: nil - ) - streamed_diff = +"" - streamed_result = +"" - start = Time.now - type = prompt_type(helper_mode) - - generate_prompt( - helper_mode, - input, - user, - force_default_locale: force_default_locale, - custom_prompt: custom_prompt, - ) do |partial_response| - streamed_result << partial_response - streamed_diff = parse_diff(input, partial_response) if type == :diff - - # Throttle updates and check for safe stream points - if (streamed_result.length > 10 && (Time.now - start > 0.3)) || Rails.env.test? - sanitized = sanitize_result(streamed_result) - - payload = { result: sanitized, diff: streamed_diff, done: false } - publish_update(channel, payload, user, client_id: client_id) - start = Time.now - end - end - - final_diff = parse_diff(input, streamed_result) if type == :diff - - sanitized_result = sanitize_result(streamed_result) - if sanitized_result.present? - publish_update( - channel, - { result: sanitized_result, diff: final_diff, done: true }, - user, - client_id: client_id, - ) - end - end - - def generate_image_caption(upload, user) - bot = build_bot(IMAGE_CAPTION, user) - force_default_locale = false - - context = - DiscourseAi::Personas::BotContext.new( - user: user, - skip_tool_details: true, - feature_name: IMAGE_CAPTION, - messages: [ - { - type: :user, - content: ["Describe this image in a single sentence.", { upload_id: upload.id }], - }, - ], - custom_instructions: custom_locale_instructions(user, force_default_locale), - ) - - structured_output = nil - - buffer_blk = - Proc.new do |partial, _, type| - if type == :structured_output - structured_output = partial - bot.persona.response_format&.first.to_h - end - end - - bot.reply(context, llm_args: { max_tokens: 1024 }, &buffer_blk) - - raw_caption = "" - - if structured_output - json_summary_schema_key = bot.persona.response_format&.first.to_h - raw_caption = - structured_output.read_buffered_property(json_summary_schema_key["key"]&.to_sym) - end - - raw_caption.delete("|").squish.truncate_words(IMAGE_CAPTION_MAX_WORDS) - end - - private - - def build_bot(helper_mode, user) - persona_id = personas_prompt_map(include_image_caption: true).invert[helper_mode] - raise Discourse::InvalidParameters.new(:mode) if persona_id.blank? - - persona_klass = AiPersona.find_by(id: persona_id)&.class_instance - return if persona_klass.nil? - - llm_model = find_ai_helper_model(helper_mode, persona_klass) - - DiscourseAi::Personas::Bot.as(user, persona: persona_klass.new, model: llm_model) - end - - def find_ai_helper_model(helper_mode, persona_klass) - if helper_mode == IMAGE_CAPTION && @image_caption_llm.is_a?(LlmModel) - return @image_caption_llm - end - - return @helper_llm if helper_mode != IMAGE_CAPTION && @helper_llm.is_a?(LlmModel) - self.class.find_ai_helper_model(helper_mode, persona_klass) - end - - # Priorities are: - # 1. Persona's default LLM - # 2. Hidden `ai_helper_model` setting, or `ai_helper_image_caption_model` for image_caption. - # 3. Newest LLM config - def self.find_ai_helper_model(helper_mode, persona_klass) - model_id = persona_klass.default_llm_id - - if !model_id - if helper_mode == IMAGE_CAPTION - model_id = SiteSetting.ai_helper_image_caption_model&.split(":")&.last - else - model_id = SiteSetting.ai_helper_model&.split(":")&.last - end - end - - if model_id.present? - LlmModel.find_by(id: model_id) - else - LlmModel.last - end - end - - def personas_prompt_map(include_image_caption: false) - map = { - SiteSetting.ai_helper_translator_persona.to_i => TRANSLATE, - SiteSetting.ai_helper_title_suggestions_persona.to_i => GENERATE_TITLES, - SiteSetting.ai_helper_proofreader_persona.to_i => PROOFREAD, - SiteSetting.ai_helper_markdown_tables_persona.to_i => MARKDOWN_TABLE, - SiteSetting.ai_helper_custom_prompt_persona.to_i => CUSTOM_PROMPT, - SiteSetting.ai_helper_explain_persona.to_i => EXPLAIN, - SiteSetting.ai_helper_post_illustrator_persona.to_i => ILLUSTRATE_POST, - SiteSetting.ai_helper_smart_dates_persona.to_i => REPLACE_DATES, - } - - if include_image_caption - image_caption_persona = SiteSetting.ai_helper_image_caption_persona.to_i - map[image_caption_persona] = IMAGE_CAPTION if image_caption_persona - end - - map - end - - def all_prompts - AiPersona - .where(id: personas_prompt_map.keys) - .map do |ai_persona| - prompt_name = personas_prompt_map[ai_persona.id] - - if prompt_name - { - name: prompt_name, - translated_name: - I18n.t("discourse_ai.ai_helper.prompts.#{prompt_name}", default: nil) || - prompt_name, - prompt_type: prompt_type(prompt_name), - icon: icon_map(prompt_name), - location: location_map(prompt_name), - allowed_group_ids: ai_persona.allowed_group_ids, - } - end - end - .compact - end - - SANITIZE_REGEX_STR = - %w[term context topic replyTo input output result] - .map { |tag| "<#{tag}>\\n?|\\n?" } - .join("|") - - SANITIZE_REGEX = Regexp.new(SANITIZE_REGEX_STR, Regexp::IGNORECASE | Regexp::MULTILINE) - - def sanitize_result(result) - result.gsub(SANITIZE_REGEX, "") - end - - def publish_update(channel, payload, user, client_id: nil) - # when publishing we make sure we do not keep large backlogs on the channel - # and make sure we clear the streaming info after 60 seconds - # this ensures we do not bloat redis - if client_id - MessageBus.publish( - channel, - payload, - user_ids: [user.id], - client_ids: [client_id], - max_backlog_age: 60, - ) - else - MessageBus.publish(channel, payload, user_ids: [user.id], max_backlog_age: 60) - end - end - - def icon_map(name) - case name - when TRANSLATE - "language" - when GENERATE_TITLES - "heading" - when PROOFREAD - "spell-check" - when MARKDOWN_TABLE - "table" - when CUSTOM_PROMPT - "comment" - when EXPLAIN - "question" - when ILLUSTRATE_POST - "images" - when REPLACE_DATES - "calendar-days" - else - nil - end - end - - def location_map(name) - case name - when TRANSLATE - %w[composer post] - when GENERATE_TITLES - %w[composer] - when PROOFREAD - %w[composer post] - when MARKDOWN_TABLE - %w[composer] - when CUSTOM_PROMPT - %w[composer post] - when EXPLAIN - %w[post] - when ILLUSTRATE_POST - %w[composer] - when REPLACE_DATES - %w[composer] - else - %w[] - end - end - - def prompt_type(prompt_name) - if [PROOFREAD, MARKDOWN_TABLE, REPLACE_DATES, CUSTOM_PROMPT].include?(prompt_name) - return :diff - end - - return :list if [ILLUSTRATE_POST, GENERATE_TITLES].include?(prompt_name) - - :text - end - - def parse_diff(text, suggestion) - cooked_text = PrettyText.cook(text) - cooked_suggestion = PrettyText.cook(suggestion) - - DiscourseDiff.new(cooked_text, cooked_suggestion).inline_html - end - end - end -end diff --git a/lib/ai_helper/chat_thread_titler.rb b/lib/ai_helper/chat_thread_titler.rb deleted file mode 100644 index 15ffc52c..00000000 --- a/lib/ai_helper/chat_thread_titler.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiHelper - class ChatThreadTitler - def initialize(thread) - @thread = thread - end - - def suggested_title - content = thread_content(@thread) - return nil if content.blank? - - suggested_title = call_llm(content) - cleanup(suggested_title) - end - - def call_llm(thread_content) - chat = "\n#{thread_content}\n" - - prompt = - DiscourseAi::Completions::Prompt.new( - <<~TEXT.strip, - I want you to act as a title generator for chat between users. I will provide you with the chat transcription, - and you will generate a single attention-grabbing title. Please keep the title concise and under 15 words - and ensure that the meaning is maintained. The title will utilize the same language type of the chat. - I want you to only reply the suggested title and nothing else, do not write explanations. - You will find the chat between XML tags. Return the suggested title between tags. - TEXT - messages: [{ type: :user, content: chat, id: "User" }], - ) - - DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model).generate( - prompt, - user: Discourse.system_user, - stop_sequences: ["</input>"], - feature_name: "chat_thread_title", - ) - end - - def cleanup(title) - (Nokogiri::HTML5.fragment(title).at("title")&.text || title) - .split("\n") - .first - .then { _1.match?(/^("|')(.*)("|')$/) ? _1[1..-2] : _1 } - .truncate(100, separator: " ") - end - - def thread_content(thread) - # TODO: Replace me by a proper API call - thread - .chat_messages - .joins(:user) - .pluck(:username, :message) - .map { |username, message| "#{username}: #{message}" } - .join("\n") - end - - attr_reader :thread - end - end -end diff --git a/lib/ai_helper/date_formatter.rb b/lib/ai_helper/date_formatter.rb deleted file mode 100644 index 382fb4d4..00000000 --- a/lib/ai_helper/date_formatter.rb +++ /dev/null @@ -1,144 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiHelper - class DateFormatter - DAYS_OF_WEEK = { - "monday" => 1, - "tuesday" => 2, - "wednesday" => 3, - "thursday" => 4, - "friday" => 5, - "saturday" => 6, - "sunday" => 0, - } - - class << self - def process_date_placeholders(text, user) - return text if !text.include?("{{") - - timezone = user.user_option.timezone || "UTC" - reference_time = Time.now.in_time_zone(timezone) - - text.gsub( - /\{\{(date_time_offset_minutes|date_offset_days|datetime|date|next_week):([^}]+)\}\}/, - ) do |match| - type = $1 - value = $2 - - case type - when "datetime" - if value.include?(":") - # Handle range like "2pm+1:3pm+2" - start_str, end_str = value.split(":") - format_datetime_range( - parse_time_with_offset(start_str, reference_time), - parse_time_with_offset(end_str, reference_time), - timezone, - ) - else - # Handle single time like "2pm+1" or "10pm" - format_date_time(parse_time_with_offset(value, reference_time), timezone) - end - when "next_week" - if value.include?(":") - # Handle range like "tuesday-1pm:tuesday-3pm" - start_str, end_str = value.split(":") - start_time = parse_next_week(start_str, reference_time) - end_time = parse_next_week(end_str, reference_time) - format_datetime_range(start_time, end_time, timezone) - else - # Handle single time like "tuesday-1pm" or just "tuesday" - time = parse_next_week(value, reference_time) - value.include?("-") ? format_date_time(time, timezone) : format_date(time, timezone) - end - when "date" - format_date(reference_time + value.to_i.days, timezone) - when "date_time_offset_minutes" - if value.include?(":") - start_offset, end_offset = value.split(":").map(&:to_i) - format_datetime_range( - reference_time + start_offset.minutes, - reference_time + end_offset.minutes, - timezone, - ) - else - format_date_time(reference_time + value.to_i.minutes, timezone) - end - when "date_offset_days" - if value.include?(":") - start_offset, end_offset = value.split(":").map(&:to_i) - format_date_range( - reference_time + start_offset.days, - reference_time + end_offset.days, - timezone, - ) - else - format_date(reference_time + value.to_i.days, timezone) - end - end - end - end - - private - - def parse_next_week(str, reference_time) - if str.include?("-") - # Handle day with time like "tuesday-1pm" - day, time = str.split("-") - target_date = get_next_week_day(day.downcase, reference_time) - parse_time(time, target_date) - else - # Just the day - get_next_week_day(str.downcase, reference_time) - end - end - - def get_next_week_day(day, reference_time) - raise ArgumentError unless DAYS_OF_WEEK.key?(day) - - target_date = reference_time + 1.week - days_ahead = DAYS_OF_WEEK[day] - target_date.wday - days_ahead += 7 if days_ahead < 0 - target_date + days_ahead.days - end - - def parse_time_with_offset(time_str, reference_time) - if time_str.include?("+") - time_part, days = time_str.split("+") - parse_time(time_part, reference_time + days.to_i.days) - else - parse_time(time_str, reference_time) - end - end - - def parse_time(time_str, reference_time) - hour = time_str.to_i - if time_str.downcase.include?("pm") && hour != 12 - hour += 12 - elsif time_str.downcase.include?("am") && hour == 12 - hour = 0 - end - - reference_time.change(hour: hour, min: 0, sec: 0) - end - - def format_date(time, timezone) - "[date=#{time.strftime("%Y-%m-%d")} timezone=\"#{timezone}\"]" - end - - def format_date_time(time, timezone) - "[date=#{time.strftime("%Y-%m-%d")} time=#{time.strftime("%H:%M:%S")} timezone=\"#{timezone}\"]" - end - - def format_date_range(start_time, end_time, timezone) - "[date-range from=#{start_time.strftime("%Y-%m-%d")} to=#{end_time.strftime("%Y-%m-%d")} timezone=\"#{timezone}\"]" - end - - def format_datetime_range(start_time, end_time, timezone) - "[date-range from=#{start_time.strftime("%Y-%m-%dT%H:%M:%S")} to=#{end_time.strftime("%Y-%m-%dT%H:%M:%S")} timezone=\"#{timezone}\"]" - end - end - end - end -end diff --git a/lib/ai_helper/entry_point.rb b/lib/ai_helper/entry_point.rb deleted file mode 100644 index cef7e2a4..00000000 --- a/lib/ai_helper/entry_point.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module AiHelper - class EntryPoint - def inject_into(plugin) - plugin.register_seedfu_fixtures( - Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_helper"), - ) - - plugin.add_to_serializer(:current_user, :can_use_assistant) do - scope.user.in_any_groups?(SiteSetting.composer_ai_helper_allowed_groups_map) - end - - plugin.add_to_serializer(:current_user, :can_use_assistant_in_post) do - scope.user.in_any_groups?(SiteSetting.post_ai_helper_allowed_groups_map) - end - - plugin.add_to_serializer(:current_user, :can_use_custom_prompts) do - scope.user.in_any_groups?(SiteSetting.ai_helper_custom_prompts_allowed_groups_map) - end - - plugin.on(:chat_message_created) do |message, channel, user, extra| - next unless SiteSetting.ai_helper_enabled - next unless SiteSetting.ai_helper_automatic_chat_thread_title - next if extra[:thread].blank? - next if extra[:thread].title.present? - - reply_count = extra[:thread].replies.count - - if reply_count.between?(1, 4) - ::Jobs.enqueue_in( - SiteSetting.ai_helper_automatic_chat_thread_title_delay.minutes, - :generate_chat_thread_title, - thread_id: extra[:thread].id, - ) - elsif reply_count >= 5 - ::Jobs.enqueue(:generate_chat_thread_title, thread_id: extra[:thread].id) - end - end - - plugin.add_to_serializer( - :current_user, - :ai_helper_prompts, - include_condition: -> { SiteSetting.ai_helper_enabled && scope.authenticated? }, - ) do - ActiveModel::ArraySerializer.new( - DiscourseAi::AiHelper::Assistant.new.available_prompts(scope.user), - root: false, - ) - end - - plugin.add_to_serializer(:current_user, :user_allowed_ai_auto_image_captions) do - scope.user.in_any_groups?(SiteSetting.ai_auto_image_caption_allowed_groups_map) - end - - UserUpdater::OPTION_ATTR.push(:auto_image_caption) - plugin.add_to_serializer( - :user_option, - :auto_image_caption, - include_condition: -> do - SiteSetting.ai_helper_enabled && - SiteSetting.ai_helper_enabled_features.include?("image_caption") && - scope.user.in_any_groups?(SiteSetting.ai_auto_image_caption_allowed_groups_map) - end, - ) { object.auto_image_caption } - - plugin.add_to_serializer( - :current_user_option, - :auto_image_caption, - include_condition: -> do - SiteSetting.ai_helper_enabled && - SiteSetting.ai_helper_enabled_features.include?("image_caption") && - scope.user.in_any_groups?(SiteSetting.ai_auto_image_caption_allowed_groups_map) - end, - ) { object.auto_image_caption } - end - end - end -end diff --git a/lib/ai_helper/painter.rb b/lib/ai_helper/painter.rb deleted file mode 100644 index 9be8b95a..00000000 --- a/lib/ai_helper/painter.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiHelper - class Painter - def commission_thumbnails(input, user) - return [] if input.blank? - - model = SiteSetting.ai_helper_illustrate_post_model - - if model == "stable_diffusion_xl" - stable_diffusion_prompt = diffusion_prompt(input, user) - return [] if stable_diffusion_prompt.blank? - - artifacts = - DiscourseAi::Inference::StabilityGenerator - .perform!(stable_diffusion_prompt) - .dig(:artifacts) - .to_a - .map { |art| art[:base64] } - - base64_to_image(artifacts, user.id) - elsif model == "dall_e_3" - attribution = - I18n.t( - "discourse_ai.ai_helper.painter.attribution.#{SiteSetting.ai_helper_illustrate_post_model}", - ) - results = - DiscourseAi::Inference::OpenAiImageGenerator.create_uploads!( - input, - model: "dall-e-3", - user_id: user.id, - title: attribution, - ) - results.map { |result| UploadSerializer.new(result[:upload], root: false) } - end - end - - private - - def base64_to_image(artifacts, user_id) - attribution = - I18n.t( - "discourse_ai.ai_helper.painter.attribution.#{SiteSetting.ai_helper_illustrate_post_model}", - ) - - artifacts.each_with_index.map do |art, i| - f = Tempfile.new("v1_txt2img_#{i}.png") - f.binmode - f.write(Base64.decode64(art)) - f.rewind - upload = UploadCreator.new(f, attribution).create_for(user_id) - f.unlink - - UploadSerializer.new(upload, root: false) - end - end - - def diffusion_prompt(text, user) - prompt = - DiscourseAi::Completions::Prompt.new( - <<~TEXT.strip, - Provide me a StableDiffusion prompt to generate an image that illustrates the following post in 40 words or less, be creative. - You'll find the post between <input></input> XML tags. - TEXT - messages: [{ type: :user, content: text, id: user.username }], - ) - - DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model).generate( - prompt, - user: user, - feature_name: "illustrate_post", - ) - end - end - end -end diff --git a/lib/ai_helper/semantic_categorizer.rb b/lib/ai_helper/semantic_categorizer.rb deleted file mode 100644 index 488741de..00000000 --- a/lib/ai_helper/semantic_categorizer.rb +++ /dev/null @@ -1,145 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module AiHelper - class SemanticCategorizer - def initialize(user, opts) - @user = user - @text = opts[:text] - @vector = DiscourseAi::Embeddings::Vector.instance - @schema = DiscourseAi::Embeddings::Schema.for(Topic) - @topic_id = opts[:topic_id] - end - - def categories - return [] if @text.blank? && @topic_id.nil? - return [] if !DiscourseAi::Embeddings.enabled? - - candidates = nearest_neighbors - return [] if candidates.empty? - - candidate_ids = candidates.map(&:first) - - ::Topic - .joins(:category) - .where(id: candidate_ids) - .where("categories.id IN (?)", Category.topic_create_allowed(@user.guardian).pluck(:id)) - .order("array_position(ARRAY#{candidate_ids}, topics.id)") - .pluck( - "categories.id", - "categories.name", - "categories.slug", - "categories.color", - "categories.topic_count", - ) - .map - .with_index do |(id, name, slug, color, topic_count), index| - { - id: id, - name: name, - slug: slug, - color: color, - topicCount: topic_count, - score: candidates[index].last, - } - end - .map do |c| - # Note: <#> returns the negative inner product since Postgres only supports ASC order index scans on operators - c[:score] = (c[:score] + 1).abs if @vector.vdef.pg_function = "<#>" - - c[:score] = 1 / (c[:score] + 1) # inverse of the distance - c - end - .group_by { |c| c[:name] } - .map { |name, scores| scores.first.merge(score: scores.sum { |s| s[:score] }) } - .sort_by { |c| -c[:score] } - .take(5) - end - - def tags - return [] if @text.blank? && @topic_id.nil? - return [] if !DiscourseAi::Embeddings.enabled? - - candidates = nearest_neighbors(limit: 100) - return [] if candidates.empty? - - candidate_ids = candidates.map(&:first) - - count_column = Tag.topic_count_column(@user.guardian) # Determine the count column - - ::Topic - .joins(:topic_tags, :tags) - .where(id: candidate_ids) - .where("tags.id IN (?)", DiscourseTagging.visible_tags(@user.guardian).pluck(:id)) - .group("topics.id") - .order("array_position(ARRAY#{candidate_ids}, topics.id)") - .pluck("array_agg(tags.name)") - .map(&:uniq) - .map - .with_index { |tag_list, index| { tags: tag_list, score: candidates[index].last } } - .flat_map { |c| c[:tags].map { |t| { name: t, score: c[:score] } } } - .map do |c| - # Note: <#> returns the negative inner product since Postgres only supports ASC order index scans on operators - c[:score] = (c[:score] + 1).abs if @vector.vdef.pg_function = "<#>" - - c[:score] = 1 / (c[:score] + 1) # inverse of the distance - c - end - .group_by { |c| c[:name] } - .map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } } - .sort_by { |c| -c[:score] } - .take(7) - .then do |tags| - models = Tag.where(name: tags.map { _1[:name] }).index_by(&:name) - tags.map do |tag| - tag[:id] = models.dig(tag[:name])&.id - tag[:count] = models.dig(tag[:name])&.public_send(count_column) || 0 - tag - end - end - end - - private - - def nearest_neighbors(limit: 50) - if @topic_id - target = Topic.find_by(id: @topic_id) - embeddings = @schema.find_by_target(target)&.embeddings - - if embeddings.blank? - @text = - DiscourseAi::Summarization::Strategies::TopicSummary - .new(target) - .targets_data - .pluck(:text) - raw_vector = @vector.vector_from(@text) - else - raw_vector = JSON.parse(embeddings) - end - else - raw_vector = @vector.vector_from(@text) - end - - muted_category_ids = nil - if @user.present? - muted_category_ids = - CategoryUser.where( - user: @user, - notification_level: CategoryUser.notification_levels[:muted], - ).pluck(:category_id) - end - - @schema - .asymmetric_similarity_search(raw_vector, limit: limit, offset: 0) do |builder| - builder.join("topics t on t.id = topic_id") - unless muted_category_ids.empty? - builder.where(<<~SQL, exclude_category_ids: muted_category_ids.map(&:to_i)) - t.category_id NOT IN (:exclude_category_ids) AND - t.category_id NOT IN (SELECT categories.id FROM categories WHERE categories.parent_category_id IN (:exclude_category_ids)) - SQL - end - end - .map { |r| [r.topic_id, r.distance] } - end - end - end -end diff --git a/lib/ai_moderation/entry_point.rb b/lib/ai_moderation/entry_point.rb deleted file mode 100644 index 122d6f3f..00000000 --- a/lib/ai_moderation/entry_point.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiModeration - class EntryPoint - def inject_into(plugin) - plugin.on(:post_created) { |post| ::DiscourseAi::AiModeration::SpamScanner.new_post(post) } - plugin.on(:post_edited) do |post| - ::DiscourseAi::AiModeration::SpamScanner.edited_post(post) - end - plugin.on(:post_process_cooked) do |_doc, post| - ::DiscourseAi::AiModeration::SpamScanner.after_cooked_post(post) - end - - plugin.on(:site_setting_changed) do |name, _old_value, new_value| - if name == :ai_spam_detection_enabled && new_value - ::DiscourseAi::AiModeration::SpamScanner.ensure_flagging_user! - end - end - - custom_filter = [ - :ai_spam_false_negative, - Proc.new do |results, value| - if value - results.where(<<~SQL) - EXISTS ( - SELECT 1 FROM ai_spam_logs - WHERE NOT is_spam - AND post_id = target_id AND target_type = 'Post' - ) - SQL - else - results - end - end, - ] - - Reviewable.add_custom_filter(custom_filter) - end - end - end -end diff --git a/lib/ai_moderation/spam_metric.rb b/lib/ai_moderation/spam_metric.rb deleted file mode 100644 index 063462fa..00000000 --- a/lib/ai_moderation/spam_metric.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiModeration - class SpamMetric - def self.update(new_status, reviewable) - return if !defined?(::DiscoursePrometheus) - ai_spam_log = AiSpamLog.find_by(reviewable:) - return if ai_spam_log.nil? - - increment("scanned") - increment("is_spam") if new_status == :approved && ai_spam_log.is_spam - increment("false_positive") if new_status == :rejected && ai_spam_log.is_spam - increment("false_negative") if new_status == :rejected && !ai_spam_log.is_spam - end - - private - - def self.increment(type, value = 1) - metric = ::DiscoursePrometheus::InternalMetric::Custom.new - metric.name = "discourse_ai_spam_detection" - metric.type = "Counter" - metric.description = "AI spam scanning statistics" - metric.labels = { db: RailsMultisite::ConnectionManagement.current_db, type: } - metric.value = value - $prometheus_client.send_json(metric.to_h) - end - end - end -end diff --git a/lib/ai_moderation/spam_report.rb b/lib/ai_moderation/spam_report.rb deleted file mode 100644 index f4574c19..00000000 --- a/lib/ai_moderation/spam_report.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiModeration - class SpamReport - def self.generate(min_date: 1.week.ago) - spam_status = [Reviewable.statuses[:approved], Reviewable.statuses[:deleted]] - ham_status = [Reviewable.statuses[:rejected], Reviewable.statuses[:ignored]] - - sql = <<~SQL - WITH spam_stats AS ( - SELECT - asl.reviewable_id, - asl.post_id, - asl.is_spam, - r.status as reviewable_status, - CASE WHEN EXISTS ( - SELECT 1 FROM reviewable_scores rs - JOIN reviewables r1 ON r1.id = rs.reviewable_id - WHERE r1.target_id = asl.post_id - AND r1.target_type = 'Post' - AND rs.reviewable_score_type = :spam_score_type - AND NOT is_spam - AND r1.status IN (:spam) - ) THEN true ELSE false END AS missed_spam - FROM ai_spam_logs asl - LEFT JOIN reviewables r ON r.id = asl.reviewable_id - WHERE asl.created_at > :min_date - ) - SELECT - COUNT(*) AS scanned_count, - SUM(CASE WHEN is_spam THEN 1 ELSE 0 END) AS spam_detected, - COUNT(CASE WHEN reviewable_status IN (:ham) THEN 1 END) AS false_positives, - COUNT(CASE WHEN missed_spam THEN 1 END) AS false_negatives - FROM spam_stats - SQL - - DB.query( - sql, - spam: spam_status, - ham: ham_status, - min_date: min_date, - spam_score_type: ReviewableScore.types[:spam], - ).first - end - end - end -end diff --git a/lib/ai_moderation/spam_scanner.rb b/lib/ai_moderation/spam_scanner.rb deleted file mode 100644 index de469f9e..00000000 --- a/lib/ai_moderation/spam_scanner.rb +++ /dev/null @@ -1,466 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module AiModeration - class SpamScanner - POSTS_TO_SCAN = 3 - MINIMUM_EDIT_DIFFERENCE = 10 - EDIT_DELAY_MINUTES = 10 - MAX_AGE_TO_SCAN = 1.day - MAX_RAW_SCAN_LENGTH = 5000 - - SHOULD_SCAN_POST_CUSTOM_FIELD = "discourse_ai_should_scan_post" - - def self.new_post(post) - return if !enabled? - return if !should_scan_post?(post) - - flag_post_for_scanning(post) - end - - def self.ensure_flagging_user! - if !SiteSetting.ai_spam_detection_user_id.present? - User.transaction do - # prefer a "high" id for this bot - id = User.where("id > -20").minimum(:id) - 1 - id = User.minimum(:id) - 1 if id == -100 - - user = - User.create!( - id: id, - username: UserNameSuggester.suggest("discourse_ai_spam"), - name: "Discourse AI Spam Scanner", - email: "#{SecureRandom.hex(10)}@invalid.invalid", - active: true, - approved: true, - trust_level: TrustLevel[4], - admin: true, - ) - Group.user_trust_level_change!(user.id, user.trust_level) - - SiteSetting.ai_spam_detection_user_id = user.id - end - end - end - - def self.flagging_user - user = nil - if SiteSetting.ai_spam_detection_user_id.present? - user = User.find_by(id: SiteSetting.ai_spam_detection_user_id) - ensure_safe_flagging_user!(user) - end - user || Discourse.system_user - end - - def self.ensure_safe_flagging_user!(user) - # only do repair on bot users, if somehow it is set to a human skip repairs - return if !user.bot? - user.update!(silenced_till: nil) if user.silenced? - user.update!(trust_level: TrustLevel[4]) if user.trust_level != TrustLevel[4] - user.update!(suspended_till: nil, suspended_at: nil) if user.suspended? - user.update!(active: true) if !user.active? - end - - def self.after_cooked_post(post) - return if !enabled? - return if !should_scan_post?(post) - return if !post.custom_fields[SHOULD_SCAN_POST_CUSTOM_FIELD] - return if post.updated_at < MAX_AGE_TO_SCAN.ago - - last_scan = AiSpamLog.where(post_id: post.id).order(created_at: :desc).first - - if last_scan && last_scan.created_at > EDIT_DELAY_MINUTES.minutes.ago - delay_minutes = - ((last_scan.created_at + EDIT_DELAY_MINUTES.minutes) - Time.current).to_i / 60 - Jobs.enqueue_in(delay_minutes.minutes, :ai_spam_scan, post_id: post.id) - else - Jobs.enqueue(:ai_spam_scan, post_id: post.id) - end - end - - def self.edited_post(post) - return if !enabled? - return if !should_scan_post?(post) - return if scanned_max_times?(post) - - previous_version = post.revisions.last&.modifications&.dig("raw", 0) - current_version = post.raw - - return if !significant_change?(previous_version, current_version) - - flag_post_for_scanning(post) - end - - def self.flag_post_for_scanning(post) - post.custom_fields[SHOULD_SCAN_POST_CUSTOM_FIELD] = "true" - post.save_custom_fields - end - - def self.enabled? - SiteSetting.ai_spam_detection_enabled && SiteSetting.discourse_ai_enabled - end - - def self.should_scan_post?(post) - return false if !post.present? - return false if post.user.trust_level > TrustLevel[1] - return false if post.topic.private_message? - return false if post.user.bot? - return false if post.user.staff? - - if Post - .where(user_id: post.user_id) - .joins(:topic) - .where(topic: { archetype: Archetype.default }) - .limit(4) - .count > 3 - return false - end - true - end - - def self.scanned_max_times?(post) - AiSpamLog.where(post_id: post.id).count >= 3 - end - - def self.significant_change?(previous_version, current_version) - return true if previous_version.nil? # First edit should be scanned - - # Use Discourse's built-in levenshtein implementation - distance = - ScreenedEmail.levenshtein(previous_version.to_s[0...1000], current_version.to_s[0...1000]) - - distance >= MINIMUM_EDIT_DIFFERENCE - end - - def self.test_post(post, custom_instructions: nil, llm_id: nil) - settings = AiModerationSetting.spam - custom_instructions = custom_instructions || settings.custom_instructions.presence - - target_msg = - build_target_content_msg( - post, - post.topic || Topic.with_deleted.find_by(id: post.topic_id), - ) - custom_insts = custom_instructions || settings.custom_instructions.presence - if custom_insts.present? - custom_insts = - "\n\nAdditional site-specific instructions provided by Staff:\n#{custom_insts}" - end - - ctx = - build_bot_context( - feature_name: "spam_detection_test", - messages: [target_msg], - custom_instructions: custom_insts, - ) - bot = build_scanner_bot(settings: settings, llm_id: llm_id) - - structured_output = nil - llm_args = { feature_context: { post_id: post.id } } - bot.reply(ctx, llm_args: llm_args) do |partial, _, type| - structured_output = partial if type == :structured_output - end - - history = nil - AiSpamLog - .where(post: post) - .order(:created_at) - .limit(100) - .each do |log| - history ||= +"Scan History:\n" - history << "date: #{log.created_at} is_spam: #{log.is_spam}\n" - end - - log = +"Scanning #{post.url}\n\n" - - if history - log << history - log << "\n" - end - - used_llm = bot.model - log << "LLM: #{used_llm.name}\n\n" - - spam_persona = bot.persona - used_prompt = spam_persona.craft_prompt(ctx, llm: used_llm).system_message_text - log << "System Prompt: #{used_prompt}\n\n" - - text_content = - if target_msg[:content].is_a?(Array) - target_msg[:content].first - else - target_msg[:content] - end - - log << "Context: #{text_content}\n\n" - - is_spam = is_spam?(structured_output) - - reasoning_insts = { - type: :user, - content: "Don't return a JSON this time. Explain your reasoning in plain text.", - } - ctx.messages = [ - target_msg, - { type: :model, content: { spam: is_spam }.to_json }, - reasoning_insts, - ] - ctx.bypass_response_format = true - - reasoning = +"" - - bot.reply(ctx, llm_args: llm_args.merge(max_tokens: 100)) do |partial, _, type| - reasoning << partial if type.blank? - end - - log << "#{reasoning.strip}" - - { is_spam: is_spam, log: log } - end - - def self.perform_scan(post) - return if !should_scan_post?(post) - - perform_scan!(post) - end - - def self.perform_scan!(post) - return if !enabled? - settings = AiModerationSetting.spam - return if !settings || !settings.llm_model || !settings.ai_persona - - target_msg = build_target_content_msg(post) - custom_instructions = settings.custom_instructions.presence - if custom_instructions.present? - custom_instructions = - "\n\nAdditional site-specific instructions provided by Staff:\n#{custom_instructions}" - end - - ctx = - build_bot_context( - messages: [target_msg], - custom_instructions: custom_instructions, - user: self.flagging_user, - ) - bot = build_scanner_bot(settings: settings, user: self.flagging_user) - structured_output = nil - - begin - llm_args = { feature_context: { post_id: post.id } } - bot.reply(ctx, llm_args: llm_args) do |partial, _, type| - structured_output = partial if type == :structured_output - end - - is_spam = is_spam?(structured_output) - - log = AiApiAuditLog.order(id: :desc).where(feature_name: "spam_detection").first - text_content = - if target_msg[:content].is_a?(Array) - target_msg[:content].first - else - target_msg[:content] - end - AiSpamLog.transaction do - log = - AiSpamLog.create!( - post: post, - llm_model: settings.llm_model, - ai_api_audit_log: log, - is_spam: is_spam, - payload: text_content, - ) - handle_spam(post, log) if is_spam - end - rescue StandardError => e - # we need retries otherwise stuff will not be handled - Discourse.warn_exception( - e, - message: "Discourse AI: Error in SpamScanner for post #{post.id}", - ) - raise e - end - end - - def self.fix_spam_scanner_not_admin - user = DiscourseAi::AiModeration::SpamScanner.flagging_user - - if user.present? - user.update!(admin: true) - else - raise Discourse::NotFound - end - end - - private - - def self.build_bot_context( - feature_name: "spam_detection", - messages:, - custom_instructions: nil, - bypass_response_format: false, - user: Discourse.system_user - ) - DiscourseAi::Personas::BotContext - .new( - user: user, - skip_tool_details: true, - feature_name: feature_name, - messages: messages, - bypass_response_format: bypass_response_format, - ) - .tap { |ctx| ctx.custom_instructions = custom_instructions if custom_instructions } - end - - def self.build_scanner_bot( - settings:, - use_structured_output: true, - llm_id: nil, - user: Discourse.system_user - ) - persona = settings.ai_persona.class_instance&.new - - llm_model = llm_id ? LlmModel.find(llm_id) : settings.llm_model - - DiscourseAi::Personas::Bot.as(user, persona: persona, model: llm_model) - end - - def self.is_spam?(structured_output) - structured_output.present? && structured_output.read_buffered_property(:spam) - end - - def self.build_target_content_msg(post, topic = nil) - topic ||= post.topic - context = [] - - # Clear distinction between reply and new topic - if post.is_first_post? - context << "NEW TOPIC POST ANALYSIS" - context << "- Topic title: #{topic.title}" - context << "- Category: #{topic.category&.name}" - else - context << "REPLY POST ANALYSIS" - context << "- In topic: #{topic.title}" - context << "- Category: #{topic.category&.name}" - context << "- Topic started by: #{topic.user&.username}" - - if post.reply_to_post_number.present? - parent = - Post.with_deleted.find_by(topic_id: topic.id, post_number: post.reply_to_post_number) - if parent - context << "\nReplying to #{parent.user&.username}'s post:" - context << "#{parent.raw[0..500]}..." if parent.raw.length > 500 - context << parent.raw if parent.raw.length <= 500 - end - end - end - - context << "\nPost Author Information:" - if user = post.user # during test we may not have a user - context << "- Username: #{user.username}\n" - context << "- Email: #{user.email}\n" - context << "- Account age: #{(Time.current - user.created_at).to_i / 86_400} days\n" - context << "- Total posts: #{user.post_count}\n" - context << "- Trust level: #{user.trust_level}\n" - if info = location_info(user) - context << "- Registration Location: #{info[:registration]}\n" if info[:registration] - context << "- Last Location: #{info[:last]}\n" if info[:last] - end - end - - context << "\nPost Content (first #{MAX_RAW_SCAN_LENGTH} chars):\n" - context << post.raw[0..MAX_RAW_SCAN_LENGTH] - - user_msg = { type: :user, content: context.join("\n") } - - upload_ids = post.upload_ids - if upload_ids.present? - user_msg[:content] = [user_msg[:content]] - upload_ids.take(3).each { |upload_id| user_msg[:content] << { upload_id: upload_id } } - end - - user_msg - end - - def self.location_info(user) - registration, last = nil - if user.ip_address.present? - info = DiscourseIpInfo.get(user.ip_address, resolve_hostname: true) - last = "#{info[:location]} (#{info[:organization]})" if info && info[:location].present? - end - if user.registration_ip_address.present? - info = DiscourseIpInfo.get(user.registration_ip_address, resolve_hostname: true) - registration = "#{info[:location]} (#{info[:organization]})" if info && - info[:location].present? - end - - rval = nil - if registration || last - rval = { registration: registration } if registration - if last && last != registration - rval ||= {} - rval[:last] = last - end - end - - rval - rescue => e - Discourse.warn_exception(e, message: "Failed to lookup location info") - nil - end - - def self.handle_spam(post, log) - url = "#{Discourse.base_url}/admin/plugins/discourse-ai/ai-spam" - reason = I18n.t("discourse_ai.spam_detection.flag_reason", url: url) - - flagging_user = self.flagging_user - - result = - PostActionCreator.new( - flagging_user, - post, - PostActionType.types[:spam], - reason: reason, - queue_for_review: true, - ).perform - - # Currently in core re-flagging something that is already flagged as spam - # is not supported, long term we may want to support this but in the meantime - # we should not be silencing/hiding if the PostActionCreator fails. - if result.success? - log.update!(reviewable: result.reviewable) - - reason = I18n.t("discourse_ai.spam_detection.silence_reason", url: url) - silencer = - UserSilencer.new( - post.user, - flagging_user, - message: :too_many_spam_flags, - post_id: post.id, - reason: reason, - keep_posts: true, - ) - silencer.silence - - # silencer will not hide tl1 posts, so we do this here - hide_post(post) - else - log.update!( - error: - "unable to flag post as spam, post action failed for post #{post.id} with error: '#{result.errors.full_messages.join(", ").truncate(3000)}'", - ) - end - end - - def self.hide_post(post) - Post.where(id: post.id).update_all( - [ - "hidden = true, hidden_reason_id = COALESCE(hidden_reason_id, ?)", - Post.hidden_reasons[:new_user_spam_threshold_reached], - ], - ) - - Topic.where(id: post.topic_id).update_all(visible: false) if post.post_number == 1 - end - end - end -end diff --git a/lib/automation.rb b/lib/automation.rb deleted file mode 100644 index 0410aed1..00000000 --- a/lib/automation.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Automation - def self.flag_types - [ - { id: "review", translated_name: I18n.t("discourse_automation.ai.flag_types.review") }, - { - id: "review_hide", - translated_name: I18n.t("discourse_automation.ai.flag_types.review_hide"), - }, - { id: "spam", translated_name: I18n.t("discourse_automation.ai.flag_types.spam") }, - { - id: "spam_silence", - translated_name: I18n.t("discourse_automation.ai.flag_types.spam_silence"), - }, - ] - end - - def self.available_custom_tools - AiTool - .where(enabled: true) - .where("parameters = '[]'::jsonb") - .pluck(:id, :name, :description) - .map { |id, name, description| { id: id, translated_name: name, description: description } } - end - - def self.available_models - values = DB.query_hash(<<~SQL) - SELECT display_name AS translated_name, id AS id - FROM llm_models - SQL - - values = - values - .filter do |value_h| - value_h["id"] > 0 || - SiteSetting.ai_automation_allowed_seeded_models_map.include?(value_h["id"].to_s) - end - .each { |value_h| value_h["id"] = "custom:#{value_h["id"]}" } - - values - end - - def self.available_persona_choices(require_user: true, require_default_llm: true) - relation = AiPersona.includes(:user) - relation = relation.where.not(user_id: nil) if require_user - relation = relation.where.not(default_llm: nil) if require_default_llm - relation.map do |persona| - phash = { id: persona.id, translated_name: persona.name, description: persona.name } - - phash[:description] += " (#{persona&.user&.username})" if require_user - - phash - end - end - end -end diff --git a/lib/automation/llm_persona_triage.rb b/lib/automation/llm_persona_triage.rb deleted file mode 100644 index dbbdbc6a..00000000 --- a/lib/automation/llm_persona_triage.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module Automation - module LlmPersonaTriage - def self.handle(post:, persona_id:, whisper: false, silent_mode: false, automation: nil) - DiscourseAi::AiBot::Playground.reply_to_post( - post: post, - persona_id: persona_id, - whisper: whisper, - silent_mode: silent_mode, - feature_name: "automation - #{automation&.name}", - ) - rescue => e - Discourse.warn_exception( - e, - message: "Error responding to: #{post&.url} in LlmPersonaTriage.handle", - ) - raise e if Rails.env.test? - nil - end - end - end -end diff --git a/lib/automation/llm_tool_triage.rb b/lib/automation/llm_tool_triage.rb deleted file mode 100644 index 58b9210d..00000000 --- a/lib/automation/llm_tool_triage.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module Automation - module LlmToolTriage - def self.handle(post:, tool_id:, automation: nil) - tool = AiTool.find_by(id: tool_id) - return if !tool - return if !tool.parameters.blank? - - context = DiscourseAi::Personas::BotContext.new(post: post) - - runner = tool.runner({}, llm: nil, bot_user: Discourse.system_user, context: context) - runner.invoke - end - end - end -end diff --git a/lib/automation/llm_triage.rb b/lib/automation/llm_triage.rb deleted file mode 100644 index 93f06712..00000000 --- a/lib/automation/llm_triage.rb +++ /dev/null @@ -1,168 +0,0 @@ -# frozen_string_literal: true -# -module DiscourseAi - module Automation - module LlmTriage - def self.handle( - post:, - model:, - search_for_text:, - system_prompt:, - category_id: nil, - tags: nil, - canned_reply: nil, - canned_reply_user: nil, - hide_topic: nil, - flag_post: nil, - flag_type: nil, - automation: nil, - max_post_tokens: nil, - stop_sequences: nil, - temperature: nil, - whisper: nil, - reply_persona_id: nil, - max_output_tokens: nil, - action: nil - ) - if category_id.blank? && tags.blank? && canned_reply.blank? && hide_topic.blank? && - flag_post.blank? && reply_persona_id.blank? - raise ArgumentError, "llm_triage: no action specified!" - end - - if action == :edit && category_id.blank? && tags.blank? && flag_post.blank? && - hide_topic.blank? - return - end - - llm = DiscourseAi::Completions::Llm.proxy(model) - - s_prompt = system_prompt.to_s.sub("%%POST%%", "") # Backwards-compat. We no longer sub this. - prompt = DiscourseAi::Completions::Prompt.new(s_prompt) - - content = "title: #{post.topic.title}\n#{post.raw}" - - content = - llm.tokenizer.truncate( - content, - max_post_tokens, - strict: SiteSetting.ai_strict_token_counting, - ) if max_post_tokens.present? - - if post.upload_ids.present? - content = [content] - content.concat(post.upload_ids.map { |upload_id| { upload_id: upload_id } }) - end - - prompt.push(type: :user, content: content) - - result = nil - - result = - llm.generate( - prompt, - max_tokens: max_output_tokens, - temperature: temperature, - user: Discourse.system_user, - stop_sequences: stop_sequences, - feature_name: "llm_triage", - feature_context: { - automation_id: automation&.id, - automation_name: automation&.name, - }, - )&.strip - - if result.present? && result.downcase.include?(search_for_text.downcase) - user = User.find_by_username(canned_reply_user) if canned_reply_user.present? - original_user = user - user = user || Discourse.system_user - if reply_persona_id.present? && action != :edit - begin - DiscourseAi::AiBot::Playground.reply_to_post( - post: post, - persona_id: reply_persona_id, - whisper: whisper, - user: original_user, - ) - rescue StandardError => e - Discourse.warn_exception( - e, - message: "Error responding to: #{post&.url} in LlmTriage.handle", - ) - raise e if Rails.env.test? - end - elsif canned_reply.present? && action != :edit - post_type = whisper ? Post.types[:whisper] : Post.types[:regular] - PostCreator.create!( - user, - topic_id: post.topic_id, - raw: canned_reply, - reply_to_post_number: post.post_number, - skip_validations: true, - post_type: post_type, - ) - end - - changes = {} - changes[:category_id] = category_id if category_id.present? - if SiteSetting.tagging_enabled? && tags.present? - changes[:tags] = post.topic.tags.map(&:name).concat(tags) - end - - if changes.present? - first_post = post.topic.posts.where(post_number: 1).first - changes[:bypass_bump] = true - changes[:skip_validations] = true - first_post.revise(Discourse.system_user, changes) - end - - post.topic.update!(visible: false) if hide_topic - - if flag_post - score_reason = - I18n - .t("discourse_automation.scriptables.llm_triage.flagged_post") - .sub("%%LLM_RESPONSE%%", result) - .sub("%%AUTOMATION_ID%%", automation&.id.to_s) - .sub("%%AUTOMATION_NAME%%", automation&.name.to_s) - - if flag_type == :spam || flag_type == :spam_silence - result = - PostActionCreator.new( - Discourse.system_user, - post, - PostActionType.types[:spam], - message: score_reason, - queue_for_review: true, - ).perform - - if flag_type == :spam_silence - if result.success? - SpamRule::AutoSilence.new(post.user, post).silence_user - else - Rails.logger.warn( - "llm_triage: unable to flag post as spam, post action failed for #{post.id} with error: '#{result.errors.full_messages.join(",").truncate(3000)}'", - ) - end - end - else - reviewable = - ReviewablePost.needs_review!(target: post, created_by: Discourse.system_user) - - reviewable.add_score( - Discourse.system_user, - ReviewableScore.types[:needs_approval], - reason: score_reason, - force_review: true, - ) - - # We cannot do this through the PostActionCreator because hiding a post is reserved for auto action flags. - # Those flags are off_topic, inappropiate, and spam. We want a more generic type for triage, so none of those - # fit here. - post.hide!(PostActionType.types[:notify_moderators]) if flag_type == :review_hide - end - end - end - end - end - end -end diff --git a/lib/automation/report_context_generator.rb b/lib/automation/report_context_generator.rb deleted file mode 100644 index e54bf235..00000000 --- a/lib/automation/report_context_generator.rb +++ /dev/null @@ -1,246 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Automation - class ReportContextGenerator - def self.generate(**args) - new(**args).generate - end - - def initialize( - start_date:, - duration:, - category_ids: nil, - tags: nil, - allow_secure_categories: false, - max_posts: 200, - tokens_per_post: 100, - tokenizer: nil, - prioritized_group_ids: [], - exclude_category_ids: nil, - exclude_tags: nil - ) - @start_date = start_date - @duration = duration - @category_ids = category_ids - @tags = tags - @allow_secure_categories = allow_secure_categories - @max_posts = max_posts - @tokenizer = tokenizer || DiscourseAi::Tokenizer::OpenAiTokenizer - @tokens_per_post = tokens_per_post - @prioritized_group_ids = prioritized_group_ids - - @posts = - Post - .where("posts.created_at >= ?", @start_date) - .joins(topic: :category) - .includes(:topic, :user) - .where("topics.visible") - .where("posts.created_at < ?", @start_date + @duration) - .where("posts.post_type = ?", Post.types[:regular]) - .where("posts.hidden_at IS NULL") - .where("topics.deleted_at IS NULL") - .where("topics.archetype = ?", Archetype.default) - @posts = @posts.where("categories.read_restricted = ?", false) if !@allow_secure_categories - @posts = @posts.where("categories.id IN (?)", @category_ids) if @category_ids.present? - @posts = - @posts.where( - "categories.id NOT IN (:ids) AND - (parent_category_id NOT IN (:ids) OR parent_category_id IS NULL)", - ids: exclude_category_ids, - ) if exclude_category_ids.present? - - if exclude_tags.present? - exclude_tag_ids = Tag.where_name(exclude_tags).select(:id) - @posts = - @posts.where( - "topics.id NOT IN (?)", - TopicTag.where(tag_id: exclude_tag_ids).select(:topic_id), - ) - end - - if @tags.present? - tag_ids = Tag.where_name(@tags).select(:id) - topic_ids_with_tags = TopicTag.where(tag_id: tag_ids).select(:topic_id) - @posts = @posts.where(topic_id: topic_ids_with_tags) - end - - if defined?(::DiscourseSolved) - @solutions = - DiscourseSolved::SolvedTopic - .where(topic_id: @posts.select(:topic_id)) - .pluck(:topic_id, :answer_post_id) - .to_h - else - @solutions = {} - end - end - - def format_topic(topic) - info = [] - info << "" - info << "### #{topic.title}" - info << "topic_id: #{topic.id}" - info << "solved: true" if @solutions.key?(topic.id) - info << "category: #{topic.category&.name}" - # We may make this optional, but for now we remove all - # tags that are not visible to anon - tags = topic.tags.visible(Guardian.new).pluck(:name) - info << "tags: #{tags.join(", ")}" if tags.present? - info << topic.created_at.strftime("%Y-%m-%d %H:%M") - { created_at: topic.created_at, info: info.join("\n"), posts: {} } - end - - def format_post(post) - buffer = [] - buffer << "" - buffer << "post_number: #{post.post_number}" - buffer << "solution: true" if @solutions[post.topic_id] == post.id - buffer << post.created_at.strftime("%Y-%m-%d %H:%M") - buffer << "user: #{post.user&.username}" - buffer << "likes: #{post.like_count}" - excerpt = - @tokenizer.truncate( - post.raw, - @tokens_per_post, - strict: SiteSetting.ai_strict_token_counting, - ) - excerpt = "excerpt: #{excerpt}..." if excerpt.length < post.raw.length - buffer << "#{excerpt}" - { likes: post.like_count, info: buffer.join("\n") } - end - - def format_summary - topic_count = - @posts - .where("topics.created_at > ?", @start_date) - .select(:topic_id) - .distinct(:topic_id) - .count - - buffer = [] - buffer << "Start Date: #{@start_date.to_date}" - buffer << "End Date: #{(@start_date + @duration).to_date}" - buffer << "New posts: #{@posts.count}" - buffer << "New topics: #{topic_count}" - - top_users = - Post - .where(id: @posts.select(:id)) - .joins(:user) - .group(:user_id, :username) - .select( - "user_id, username, sum(posts.like_count) like_count, count(posts.id) post_count", - ) - .order("sum(posts.like_count) desc") - .limit(10) - - buffer << "Top users:" - top_users.each do |user| - buffer << "@#{user.username} (#{user.like_count} likes, #{user.post_count} posts)" - end - - if @prioritized_group_ids.present? - group_names = - Group - .where(id: @prioritized_group_ids) - .pluck(:name, :full_name) - .map do |name, full_name| - if full_name.present? - "#{name} (#{full_name[0..100].gsub("\n", " ")})" - else - name - end - end - .join(", ") - buffer << "" - buffer << "Top users in #{group_names} group#{group_names.include?(",") ? "s" : ""}:" - - group_users = GroupUser.where(group_id: @prioritized_group_ids).select(:user_id) - top_users - .where(user_id: group_users) - .each do |user| - buffer << "@#{user.username} (#{user.like_count} likes, #{user.post_count} posts)" - end - end - - buffer.join("\n") - end - - def format_topics - buffer = [] - topics = {} - - post_count = 0 - - @posts = @posts.order("posts.like_count desc, posts.created_at desc") - - if @prioritized_group_ids.present? - user_groups = GroupUser.where(group_id: @prioritized_group_ids) - prioritized_posts = @posts.where(user_id: user_groups.select(:user_id)).limit(@max_posts) - - post_count += add_posts(prioritized_posts, topics) - end - - add_posts(@posts.limit(@max_posts), topics, limit: @max_posts - post_count) - - # we need last posts in all topics - # they may have important info - last_posts = - @posts.where("posts.post_number = topics.highest_post_number").where( - "topics.id IN (?)", - topics.keys, - ) - - add_posts(last_posts, topics) - - topics.each do |topic_id, topic_info| - topic_info[:post_likes] = topic_info[:posts].sum { |_, post_info| post_info[:likes] } - end - - topics = topics.sort { |a, b| b[1][:post_likes] <=> a[1][:post_likes] } - - topics.each do |topic_id, topic_info| - buffer << topic_info[:info] - - last_post_number = 0 - - topic_info[:posts] - .sort { |a, b| a[0] <=> b[0] } - .each do |post_number, post_info| - buffer << "\n..." if post_number > last_post_number + 1 - buffer << post_info[:info] - last_post_number = post_number - end - end - - buffer.join("\n") - end - - def generate - buffer = [] - - buffer << "## Summary" - buffer << format_summary - buffer << "\n## Topics" - buffer << format_topics - - buffer.join("\n") - end - - def add_posts(relation, topics, limit: nil) - post_count = 0 - relation.each do |post| - topics[post.topic_id] ||= format_topic(post.topic) - if !topics[post.topic_id][:posts][post.post_number] - topics[post.topic_id][:posts][post.post_number] = format_post(post) - post_count += 1 - limit -= 1 if limit - end - break if limit && limit <= 0 - end - post_count - end - end - end -end diff --git a/lib/automation/report_runner.rb b/lib/automation/report_runner.rb deleted file mode 100644 index 1c207a45..00000000 --- a/lib/automation/report_runner.rb +++ /dev/null @@ -1,270 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Automation - class ReportRunner - def self.default_instructions - # not localizing for now cause non English LLM will require - # a fair bit of experimentation - <<~TEXT - Generate report: - - ## Report Guidelines: - - - Length & Style: Aim for 12 dense paragraphs in a narrative style, focusing on internal forum discussions. - - Accuracy: Only include verified information with no embellishments. - - Sourcing: ALWAYS Back statements with links to forum discussions. - - Markdown Usage: Enhance readability with **bold**, *italic*, and > quotes. - - Linking: Use `#{Discourse.base_url}/t/-/TOPIC_ID/POST_NUMBER` for direct references. - - User Mentions: Reference users with @USERNAME - - Add many topic links: strive to link to at least 30 topics in the report. Topic Id is meaningless to end users if you need to throw in a link use [ref](...) or better still just embed it into the [sentence](...) - - Categories and tags: use the format #TAG and #CATEGORY to denote tags and categories - - ## Structure: - - - Key statistics: Specify date range, call out important stats like number of new topics and posts - - Overview: Briefly state trends within period. - - Highlighted content: 5 paragraphs highlighting important topics people should know about. If possible have each paragraph link to multiple related topics. - - Key insights and trends linking to a selection of posts that back them - TEXT - end - - def self.run!(**args) - new(**args).run! - end - - def initialize( - sender_username:, - model:, - persona_id:, - sample_size:, - instructions:, - tokens_per_post:, - days:, - offset:, - receivers: nil, - topic_id: nil, - title: nil, - category_ids: nil, - tags: nil, - priority_group_id: nil, - allow_secure_categories: false, - debug_mode: false, - exclude_category_ids: nil, - exclude_tags: nil, - top_p: 0.1, - temperature: 0.2, - suppress_notifications: false, - automation: nil - ) - @sender = User.find_by(username: sender_username) - receivers_without_emails = receivers&.reject { |r| r.include? "@" } - if receivers_without_emails.present? - @group_receivers = Group.where(name: receivers_without_emails) - receivers_without_emails -= @group_receivers.pluck(:name) - @receivers = User.where(username: receivers_without_emails) - else - @group_receivers = [] - @receivers = [] - end - @email_receivers = receivers&.filter { |r| r.include? "@" } - @title = - if title.present? - title - else - I18n.t("discourse_automation.scriptables.llm_report.title") - end - @model = LlmModel.find_by(id: model.split(":")&.last) - @persona = AiPersona.find(persona_id).class_instance.new - @category_ids = category_ids - @tags = tags - @allow_secure_categories = allow_secure_categories - @debug_mode = debug_mode - @sample_size = sample_size.to_i < 10 ? 10 : sample_size.to_i - @instructions = instructions - @days = days.to_i - @offset = offset.to_i - @priority_group_id = priority_group_id - @tokens_per_post = tokens_per_post.to_i - @topic_id = topic_id.presence&.to_i - @exclude_category_ids = exclude_category_ids - @exclude_tags = exclude_tags - - @top_p = top_p - @temperature = temperature - - @top_p = nil if top_p.to_f < 0 - @temperature = nil if temperature.to_f < 0 - @suppress_notifications = suppress_notifications - - if !@topic_id && !@receivers.present? && !@group_receivers.present? && - !@email_receivers.present? - raise ArgumentError, "Must specify topic_id or receivers" - end - @automation = automation - end - - def run! - start_date = (@offset + @days).days.ago - end_date = start_date + @days.days - - title = - @title.gsub( - "%DATE%", - start_date.strftime("%Y-%m-%d") + " - " + end_date.strftime("%Y-%m-%d"), - ) - - prioritized_group_ids = [@priority_group_id] if @priority_group_id.present? - context = - DiscourseAi::Automation::ReportContextGenerator.generate( - start_date: start_date, - duration: @days.days, - max_posts: @sample_size, - tags: @tags, - category_ids: @category_ids, - prioritized_group_ids: prioritized_group_ids, - allow_secure_categories: @allow_secure_categories, - tokens_per_post: @tokens_per_post, - tokenizer: @model.tokenizer_class, - exclude_category_ids: @exclude_category_ids, - exclude_tags: @exclude_tags, - ) - input = <<~INPUT.strip - #{@instructions} - - Real and accurate context from the Discourse forum is included in the <context> tag below. - - <context> - #{context} - </context> - - #{@instructions} - INPUT - - report_ctx = - DiscourseAi::Personas::BotContext.new( - user: Discourse.system_user, - skip_tool_details: true, - feature_name: "ai_report", - messages: [{ type: :user, content: input }], - ) - - puts if Rails.env.development? && @debug_mode - - result = +"" - bot = DiscourseAi::Personas::Bot.as(Discourse.system_user, persona: @persona, model: @model) - json_summary_schema_key = @persona.response_format&.first.to_h - - buffer_blk = - Proc.new do |partial, _, type| - if type == :structured_output - read_chunk = partial.read_buffered_property(json_summary_schema_key["key"]&.to_sym) - - print read_chunk if Rails.env.development? && @debug_mode - result << read_chunk if read_chunk.present? - elsif type.blank? - # Assume response is a regular completion. - print partial if Rails.env.development? && @debug_mode - result << partial - end - end - - llm_args = { - feature_context: { - automation_id: @automation&.id, - automation_name: @automation&.name, - }, - } - bot.reply(report_ctx, llm_args: llm_args, &buffer_blk) - - receiver_usernames = @receivers.map(&:username).join(",") - receiver_groupnames = @group_receivers.map(&:name).join(",") - - result = suppress_notifications(result) if @suppress_notifications - - if @topic_id - PostCreator.create!(@sender, raw: result, topic_id: @topic_id, skip_validations: true) - # no debug mode for topics, it is too noisy - end - - if receiver_usernames.present? || receiver_groupnames.present? - post = - PostCreator.create!( - @sender, - raw: result, - title: title, - archetype: Archetype.private_message, - target_usernames: receiver_usernames, - target_group_names: receiver_groupnames, - skip_validations: true, - ) - - if @debug_mode - input = input.split("\n").map { |line| " #{line}" }.join("\n") - raw = <<~RAW - ``` - tokens: #{@model.tokenizer_class.tokenize(input).length} - start_date: #{start_date}, - duration: #{@days.days}, - max_posts: #{@sample_size}, - tags: #{@tags}, - category_ids: #{@category_ids}, - priority_group: #{@priority_group_id} - model: #{@model.display_name} - temperature: #{@temperature} - top_p: #{@top_p} - LLM context was: - ``` - - #{input} - RAW - PostCreator.create!(@sender, raw: raw, topic_id: post.topic_id, skip_validations: true) - end - end - - if @email_receivers.present? - @email_receivers.each do |to_address| - Email::Sender.new( - ::AiReportMailer.send_report(to_address, subject: title, body: result), - :ai_report, - ).send - end - end - end - - private - - def suppress_notifications(raw) - cooked = PrettyText.cook(raw, sanitize: false) - parsed = Nokogiri::HTML5.fragment(cooked) - - parsed - .css("a") - .each do |a| - if a["class"] == "mention" - a.inner_html = a.inner_html.sub("@", "") - next - end - href = a["href"] - if href.present? && (href.start_with?("#{Discourse.base_url}") || href.start_with?("/")) - begin - uri = URI.parse(href) - if uri.query.present? - params = CGI.parse(uri.query) - params["silent"] = "true" - uri.query = URI.encode_www_form(params) - else - uri.query = "silent=true" - end - a["href"] = uri.to_s - rescue URI::InvalidURIError - # skip - end - end - end - - parsed.to_html - end - end - end -end diff --git a/lib/completions/anthropic_message_processor.rb b/lib/completions/anthropic_message_processor.rb deleted file mode 100644 index a8ba9b4a..00000000 --- a/lib/completions/anthropic_message_processor.rb +++ /dev/null @@ -1,178 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Completions::AnthropicMessageProcessor - class AnthropicToolCall - attr_reader :name, :raw_json, :id - - def initialize(name, id, partial_tool_calls: false) - @name = name - @id = id - @raw_json = +"" - @tool_call = DiscourseAi::Completions::ToolCall.new(id: id, name: name, parameters: {}) - @streaming_parser = - DiscourseAi::Completions::JsonStreamingTracker.new(self) if partial_tool_calls - end - - def append(json) - @raw_json << json - @streaming_parser << json if @streaming_parser - end - - def notify_progress(key, value) - @tool_call.partial = true - @tool_call.parameters[key.to_sym] = value - @has_new_data = true - end - - def has_partial? - @has_new_data - end - - def partial_tool_call - @has_new_data = false - @tool_call - end - - def to_tool_call - parameters = {} - parameters = JSON.parse(raw_json, symbolize_names: true) if raw_json.present? - # we dupe to avoid poisoning the original tool call - @tool_call = @tool_call.dup - @tool_call.partial = false - @tool_call.parameters = parameters - @tool_call - end - end - - attr_reader :tool_calls, :input_tokens, :output_tokens, :output_thinking - - def initialize(streaming_mode:, partial_tool_calls: false, output_thinking: false) - @streaming_mode = streaming_mode - @tool_calls = [] - @current_tool_call = nil - @partial_tool_calls = partial_tool_calls - @output_thinking = output_thinking - @thinking = nil - end - - def to_tool_calls - @tool_calls.map { |tool_call| tool_call.to_tool_call } - end - - def process_streamed_message(parsed) - result = nil - if parsed[:type] == "content_block_start" && parsed.dig(:content_block, :type) == "tool_use" - tool_name = parsed.dig(:content_block, :name) - tool_id = parsed.dig(:content_block, :id) - result = @current_tool_call.to_tool_call if @current_tool_call - @current_tool_call = - AnthropicToolCall.new( - tool_name, - tool_id, - partial_tool_calls: @partial_tool_calls, - ) if tool_name - elsif parsed[:type] == "content_block_start" && parsed.dig(:content_block, :type) == "thinking" - if @output_thinking - @thinking = - DiscourseAi::Completions::Thinking.new( - message: +parsed.dig(:content_block, :thinking).to_s, - signature: +"", - partial: true, - ) - result = @thinking.dup - end - elsif parsed[:type] == "content_block_delta" && parsed.dig(:delta, :type) == "thinking_delta" - if @output_thinking - delta = parsed.dig(:delta, :thinking) - @thinking.message << delta if @thinking - result = DiscourseAi::Completions::Thinking.new(message: delta, partial: true) - end - elsif parsed[:type] == "content_block_delta" && parsed.dig(:delta, :type) == "signature_delta" - if @output_thinking - @thinking.signature << parsed.dig(:delta, :signature) if @thinking - end - elsif parsed[:type] == "content_block_stop" && @thinking - @thinking.partial = false - result = @thinking - @thinking = nil - elsif parsed[:type] == "content_block_start" || parsed[:type] == "content_block_delta" - if @current_tool_call - tool_delta = parsed.dig(:delta, :partial_json).to_s - @current_tool_call.append(tool_delta) - result = @current_tool_call.partial_tool_call if @current_tool_call.has_partial? - elsif parsed.dig(:content_block, :type) == "redacted_thinking" - if @output_thinking - result = - DiscourseAi::Completions::Thinking.new( - message: nil, - signature: parsed.dig(:content_block, :data), - redacted: true, - ) - end - else - result = parsed.dig(:delta, :text).to_s - # no need to return empty strings for streaming, no value - result = nil if result == "" - end - elsif parsed[:type] == "content_block_stop" - if @current_tool_call - result = @current_tool_call.to_tool_call - @current_tool_call = nil - end - elsif parsed[:type] == "message_start" - @input_tokens = parsed.dig(:message, :usage, :input_tokens) - elsif parsed[:type] == "message_delta" - @output_tokens = - parsed.dig(:usage, :output_tokens) || parsed.dig(:delta, :usage, :output_tokens) - elsif parsed[:type] == "message_stop" - # bedrock has this ... - if bedrock_stats = parsed.dig("amazon-bedrock-invocationMetrics".to_sym) - @input_tokens = bedrock_stats[:inputTokenCount] || @input_tokens - @output_tokens = bedrock_stats[:outputTokenCount] || @output_tokens - end - end - result - end - - def process_message(payload) - result = "" - parsed = payload - parsed = JSON.parse(payload, symbolize_names: true) if payload.is_a?(String) - - content = parsed.dig(:content) - if content.is_a?(Array) - result = - content - .map do |data| - if data[:type] == "tool_use" - call = AnthropicToolCall.new(data[:name], data[:id]) - call.append(data[:input].to_json) - call.to_tool_call - elsif data[:type] == "thinking" - if @output_thinking - DiscourseAi::Completions::Thinking.new( - message: data[:thinking], - signature: data[:signature], - ) - end - elsif data[:type] == "redacted_thinking" - if @output_thinking - DiscourseAi::Completions::Thinking.new( - message: nil, - signature: data[:data], - redacted: true, - ) - end - else - data[:text] - end - end - .compact - end - - @input_tokens = parsed.dig(:usage, :input_tokens) - @output_tokens = parsed.dig(:usage, :output_tokens) - - result - end -end diff --git a/lib/completions/cancel_manager.rb b/lib/completions/cancel_manager.rb deleted file mode 100644 index 78c3ee5b..00000000 --- a/lib/completions/cancel_manager.rb +++ /dev/null @@ -1,109 +0,0 @@ -# frozen_string_literal: true - -# special object that can be used to cancel completions and http requests -module DiscourseAi - module Completions - class CancelManager - attr_reader :cancelled - attr_reader :callbacks - - def initialize - @cancelled = false - @callbacks = Concurrent::Array.new - @mutex = Mutex.new - @monitor_thread = nil - end - - def monitor_thread - @mutex.synchronize { @monitor_thread } - end - - def start_monitor(delay: 0.5, &block) - @mutex.synchronize do - raise "Already monitoring" if @monitor_thread - raise "Expected a block" if !block - - db = RailsMultisite::ConnectionManagement.current_db - @stop_monitor = false - - @monitor_thread = - Thread.new do - begin - loop do - done = false - @mutex.synchronize { done = true if @stop_monitor } - break if done - sleep delay - @mutex.synchronize { done = true if @stop_monitor } - @mutex.synchronize { done = true if cancelled? } - break if done - - should_cancel = false - RailsMultisite::ConnectionManagement.with_connection(db) do - should_cancel = block.call - end - - @mutex.synchronize { cancel! if should_cancel } - - break if cancelled? - end - ensure - @mutex.synchronize { @monitor_thread = nil } - end - end - end - end - - def stop_monitor - monitor_thread = nil - - @mutex.synchronize { monitor_thread = @monitor_thread } - - if monitor_thread - @mutex.synchronize { @stop_monitor = true } - # so we do not deadlock - monitor_thread.wakeup - monitor_thread.join(2) - # should not happen - if monitor_thread.alive? - Rails.logger.warn("DiscourseAI: CancelManager monitor thread did not stop in time") - monitor_thread.kill if monitor_thread.alive? - end - @monitor_thread = nil - end - end - - def cancelled? - @cancelled - end - - def add_callback(cb) - @callbacks << cb - end - - def remove_callback(cb) - @callbacks.delete(cb) - end - - def cancel! - @cancelled = true - monitor_thread = @monitor_thread - if monitor_thread && monitor_thread != Thread.current - monitor_thread.wakeup - monitor_thread.join(2) - if monitor_thread.alive? - Rails.logger.warn("DiscourseAI: CancelManager monitor thread did not stop in time") - monitor_thread.kill if monitor_thread.alive? - end - end - @callbacks.each do |cb| - begin - cb.call - rescue StandardError - # ignore cause this may have already been cancelled - end - end - end - end - end -end diff --git a/lib/completions/dialects/chat_gpt.rb b/lib/completions/dialects/chat_gpt.rb deleted file mode 100644 index 3e29dcdd..00000000 --- a/lib/completions/dialects/chat_gpt.rb +++ /dev/null @@ -1,178 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class ChatGpt < Dialect - class << self - def can_translate?(llm_model) - llm_model.provider == "open_router" || llm_model.provider == "open_ai" || - llm_model.provider == "azure" - end - end - - VALID_ID_REGEX = /\A[a-zA-Z0-9_]+\z/ - - def native_tool_support? - llm_model.provider == "open_ai" || llm_model.provider == "azure" - end - - def embed_user_ids? - return @embed_user_ids if defined?(@embed_user_ids) - - @embed_user_ids = true if responses_api? - - @embed_user_ids ||= - prompt.messages.any? do |m| - m[:id] && m[:type] == :user && !m[:id].to_s.match?(VALID_ID_REGEX) - end - end - - def responses_api? - return @responses_api if defined?(@responses_api) - @responses_api = llm_model.lookup_custom_param("enable_responses_api") - end - - def max_prompt_tokens - # provide a buffer of 120 tokens - our function counting is not - # 100% accurate and getting numbers to align exactly is very hard - buffer = (opts[:max_tokens] || 2500) + 50 - - if tools.present? - # note this is about 100 tokens over, OpenAI have a more optimal representation - @function_size ||= llm_model.tokenizer_class.size(tools.to_json.to_s) - buffer += @function_size - end - - llm_model.max_prompt_tokens - buffer - end - - def disable_native_tools? - return @disable_native_tools if defined?(@disable_native_tools) - !!@disable_native_tools = llm_model.lookup_custom_param("disable_native_tools") - end - - private - - def tools_dialect - if disable_native_tools? - super - else - @tools_dialect ||= - DiscourseAi::Completions::Dialects::OpenAiTools.new( - prompt.tools, - responses_api: responses_api?, - ) - end - end - - # developer messages are preferred on recent reasoning models - def supports_developer_messages? - !legacy_reasoning_model? && llm_model.provider == "open_ai" && - (llm_model.name.start_with?("o1") || llm_model.name.start_with?("o3")) - end - - def legacy_reasoning_model? - llm_model.provider == "open_ai" && - (llm_model.name.start_with?("o1-preview") || llm_model.name.start_with?("o1-mini")) - end - - def system_msg(msg) - content = msg[:content] - if disable_native_tools? && tools_dialect.instructions.present? - content = content + "\n\n" + tools_dialect.instructions - end - - if supports_developer_messages? - { role: "developer", content: content } - elsif legacy_reasoning_model? - { role: "user", content: content } - else - { role: "system", content: content } - end - end - - def model_msg(msg) - { role: "assistant", content: msg[:content] } - end - - def tool_call_msg(msg) - if disable_native_tools? - super - else - tools_dialect.from_raw_tool_call(msg) - end - end - - def tool_msg(msg) - if disable_native_tools? - super - else - tools_dialect.from_raw_tool(msg) - end - end - - def user_msg(msg) - content_array = [] - - user_message = { role: "user" } - - if msg[:id] - if embed_user_ids? - content_array << "#{msg[:id]}: " - else - user_message[:name] = msg[:id] - end - end - - content_array << msg[:content] - - content_array = - to_encoded_content_array( - content: content_array.flatten, - image_encoder: ->(details) { image_node(details) }, - text_encoder: ->(text) { text_node(text) }, - allow_vision: vision_support?, - ) - - user_message[:content] = no_array_if_only_text(content_array) - user_message - end - - def no_array_if_only_text(content_array) - if content_array.size == 1 && content_array.first[:type] == "text" - content_array.first[:text] - else - content_array - end - end - - def text_node(text) - if responses_api? - { type: "input_text", text: text } - else - { type: "text", text: text } - end - end - - def image_node(details) - encoded_image = "data:#{details[:mime_type]};base64,#{details[:base64]}" - if responses_api? - { type: "input_image", image_url: encoded_image } - else - { type: "image_url", image_url: { url: encoded_image } } - end - end - - def per_message_overhead - # open ai defines about 4 tokens per message of overhead - 4 - end - - def calculate_message_token(context) - llm_model.tokenizer_class.size(context[:content].to_s + context[:name].to_s) - end - end - end - end -end diff --git a/lib/completions/dialects/claude.rb b/lib/completions/dialects/claude.rb deleted file mode 100644 index 0ad75624..00000000 --- a/lib/completions/dialects/claude.rb +++ /dev/null @@ -1,169 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class Claude < Dialect - class << self - def can_translate?(llm_model) - llm_model.provider == "anthropic" || - (llm_model.provider == "aws_bedrock") && - (llm_model.name.include?("anthropic") || llm_model.name.include?("claude")) - end - end - - class ClaudePrompt - attr_reader :system_prompt, :messages, :tools, :tool_choice - - def initialize(system_prompt, messages, tools, tool_choice) - @system_prompt = system_prompt - @messages = messages - @tools = tools - @tool_choice = tool_choice - end - - def has_tools? - tools.present? - end - end - - def translate - messages = super - - system_prompt = messages.shift[:content] if messages.first[:role] == "system" - - if !system_prompt && !native_tool_support? - system_prompt = tools_dialect.instructions.presence - end - - interleving_messages = [] - previous_message = nil - - messages.each do |message| - if previous_message - if previous_message[:role] == "user" && message[:role] == "user" - interleving_messages << { role: "assistant", content: "OK" } - elsif previous_message[:role] == "assistant" && message[:role] == "assistant" - interleving_messages << { role: "user", content: "OK" } - end - end - interleving_messages << message - previous_message = message - end - - tools = nil - tools = tools_dialect.translated_tools if native_tool_support? - - ClaudePrompt.new(system_prompt.presence, interleving_messages, tools, tool_choice) - end - - def max_prompt_tokens - llm_model.max_prompt_tokens - end - - def native_tool_support? - !llm_model.lookup_custom_param("disable_native_tools") - end - - private - - def tools_dialect - if native_tool_support? - @tools_dialect ||= DiscourseAi::Completions::Dialects::ClaudeTools.new(prompt.tools) - else - super - end - end - - def tool_call_msg(msg) - translated = tools_dialect.from_raw_tool_call(msg) - { role: "assistant", content: translated } - end - - def tool_msg(msg) - translated = tools_dialect.from_raw_tool(msg) - { role: "user", content: translated } - end - - def model_msg(msg) - content_array = [] - - if msg[:thinking] || msg[:redacted_thinking_signature] - if msg[:thinking] - content_array << { - type: "thinking", - thinking: msg[:thinking], - signature: msg[:thinking_signature], - } - end - - if msg[:redacted_thinking_signature] - content_array << { - type: "redacted_thinking", - data: msg[:redacted_thinking_signature], - } - end - end - - # other encoder is used to pass through thinking - content_array = - to_encoded_content_array( - content: [content_array, msg[:content]].flatten, - image_encoder: ->(details) {}, - text_encoder: ->(text) { { type: "text", text: text } }, - other_encoder: ->(details) { details }, - allow_vision: false, - ) - - { role: "assistant", content: no_array_if_only_text(content_array) } - end - - def system_msg(msg) - msg = { role: "system", content: msg[:content] } - - if tools_dialect.instructions.present? - msg[:content] = msg[:content].dup << "\n\n#{tools_dialect.instructions}" - end - - msg - end - - def user_msg(msg) - content_array = [] - content_array << "#{msg[:id]}: " if msg[:id] - content_array.concat([msg[:content]].flatten) - - content_array = - to_encoded_content_array( - content: content_array, - image_encoder: ->(details) { image_node(details) }, - text_encoder: ->(text) { { type: "text", text: text } }, - allow_vision: vision_support?, - ) - - { role: "user", content: no_array_if_only_text(content_array) } - end - - # keeping our payload as backward compatible as possible - def no_array_if_only_text(content_array) - if content_array.length == 1 && content_array.first[:type] == "text" - content_array.first[:text] - else - content_array - end - end - - def image_node(details) - { - source: { - type: "base64", - data: details[:base64], - media_type: details[:mime_type], - }, - type: "image", - } - end - end - end - end -end diff --git a/lib/completions/dialects/claude_tools.rb b/lib/completions/dialects/claude_tools.rb deleted file mode 100644 index 48ef0232..00000000 --- a/lib/completions/dialects/claude_tools.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class ClaudeTools - def initialize(tools) - @raw_tools = tools - end - - def translated_tools - raw_tools.map do |t| - { name: t.name, description: t.description, input_schema: t.parameters_json_schema } - end - end - - def instructions - "" - end - - def from_raw_tool_call(raw_message) - call_details = JSON.parse(raw_message[:content], symbolize_names: true) - result = [] - - if raw_message[:thinking] || raw_message[:redacted_thinking_signature] - if raw_message[:thinking] - result << { - type: "thinking", - thinking: raw_message[:thinking], - signature: raw_message[:thinking_signature], - } - end - - if raw_message[:redacted_thinking_signature] - result << { - type: "redacted_thinking", - data: raw_message[:redacted_thinking_signature], - } - end - end - - tool_call_id = raw_message[:id] - - result << { - type: "tool_use", - id: tool_call_id, - name: raw_message[:name], - input: call_details[:arguments], - } - - result - end - - def from_raw_tool(raw_message) - [{ type: "tool_result", tool_use_id: raw_message[:id], content: raw_message[:content] }] - end - - private - - attr_reader :raw_tools - end - end - end -end diff --git a/lib/completions/dialects/cohere_tools.rb b/lib/completions/dialects/cohere_tools.rb deleted file mode 100644 index 26eb3bc7..00000000 --- a/lib/completions/dialects/cohere_tools.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class CohereTools - def initialize(tools) - @raw_tools = tools - end - - def tool_results(messages) - pairs = [] - - current_pair = nil - messages.each do |msg| - if current_pair == nil && msg[:type] == :tool_call - current_pair = [msg] - elsif current_pair && msg[:type] == :tool - current_pair << msg - pairs << current_pair - current_pair = nil - else - current_pair = nil - end - end - - pairs.map do |call, result| - params = JSON.parse(call[:content])["arguments"] - { - call: { - name: call[:name] == "search" ? "search_local" : call[:name], - parameters: params, - generation_id: call[:id], - }, - outputs: [JSON.parse(result[:content])], - } - end - end - - def translated_tools - raw_tools.map do |tool| - defs = {} - - tool.parameters.each do |p| - name = p.name - defs[name] = { - description: p.description, - type: cohere_type(p.type, p.item_type), - required: p.required, - } - - #defs[name][:default] = p.default if p.default - end - - { - name: tool.name == "search" ? "search_local" : tool.name, - description: tool.description, - parameter_definitions: defs, - } - end - end - - def instructions - "" # Noop. Tools are listed separate. - end - - private - - attr_reader :raw_tools - - def cohere_type(type, item_type) - type = type.to_s - case type - when "string" - "str" - when "number" - item_type == "integer" ? "int" : "float" - when "boolean" - "bool" - when "object" - item_type ? "Dict[#{item_type}]" : "Dict" - when "array" - item_type ? "List[#{item_type}]" : "List" - else - type - end - end - end - end - end -end diff --git a/lib/completions/dialects/command.rb b/lib/completions/dialects/command.rb deleted file mode 100644 index ce390d8e..00000000 --- a/lib/completions/dialects/command.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -# see: https://docs.cohere.com/reference/chat -# -module DiscourseAi - module Completions - module Dialects - class Command < Dialect - def self.can_translate?(llm_model) - llm_model.provider == "cohere" - end - - VALID_ID_REGEX = /\A[a-zA-Z0-9_]+\z/ - - def translate - messages = super - - system_message = messages.shift[:message] if messages.first[:role] == "SYSTEM" - - prompt = { preamble: +"#{system_message}" } - - if messages.present? - with_mapped_tools = [] - - current_pair = nil - messages.each do |msg| - if current_pair == nil && msg[:type] == :tool_call - current_pair = [msg] - elsif current_pair && msg[:type] == :tool - current_pair << msg - tool_results = tools_dialect.tool_results(current_pair) - with_mapped_tools << { role: "TOOL", message: "", tool_results: tool_results } - current_pair = nil - else - with_mapped_tools << msg - current_pair = nil - end - end - - messages = with_mapped_tools - prompt[:chat_history] = messages - end - - tools = tools_dialect.translated_tools - prompt[:tools] = tools if tools.present? - - tool_results = - messages.last && messages.last[:role] == "TOOL" && messages.last[:tool_results] - prompt[:tool_results] = tool_results if tool_results.present? - - if tool_results.blank? - messages.reverse_each do |msg| - if msg[:role] == "USER" - prompt[:message] = msg[:message] - messages.delete(msg) - break - end - end - end - - prompt - end - - def max_prompt_tokens - llm_model.max_prompt_tokens - end - - def native_tool_support? - true - end - - private - - def tools_dialect - @tools_dialect ||= DiscourseAi::Completions::Dialects::CohereTools.new(prompt.tools) - end - - def per_message_overhead - 0 - end - - def calculate_message_token(context) - llm_model.tokenizer_class.size(context[:content].to_s + context[:name].to_s) - end - - def system_msg(msg) - cmd_msg = { role: "SYSTEM", message: msg[:content] } - - if tools_dialect.instructions.present? - cmd_msg[:message] = [ - msg[:content], - tools_dialect.instructions, - "NEVER attempt to run tools using JSON, always use XML. Lives depend on it.", - ].join("\n") - end - - cmd_msg - end - - def model_msg(msg) - { role: "CHATBOT", message: msg[:content] } - end - - def tool_call_msg(msg) - msg - end - - def tool_msg(msg) - msg - end - - def user_msg(msg) - content = DiscourseAi::Completions::Prompt.text_only(msg) - user_message = { role: "USER", message: content } - user_message[:message] = "#{msg[:id]}: #{content}" if msg[:id] - user_message - end - end - end - end -end diff --git a/lib/completions/dialects/dialect.rb b/lib/completions/dialects/dialect.rb deleted file mode 100644 index 82f2aff3..00000000 --- a/lib/completions/dialects/dialect.rb +++ /dev/null @@ -1,266 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class Dialect - class << self - def can_translate?(llm_model) - raise NotImplemented - end - - def all_dialects - [ - DiscourseAi::Completions::Dialects::ChatGpt, - DiscourseAi::Completions::Dialects::Gemini, - DiscourseAi::Completions::Dialects::Claude, - DiscourseAi::Completions::Dialects::Command, - DiscourseAi::Completions::Dialects::Ollama, - DiscourseAi::Completions::Dialects::Mistral, - DiscourseAi::Completions::Dialects::Nova, - DiscourseAi::Completions::Dialects::OpenAiCompatible, - ] - end - - def dialect_for(llm_model) - dialects = [] - - if Rails.env.test? || Rails.env.development? - dialects = [DiscourseAi::Completions::Dialects::Fake] - end - - dialects = dialects.concat(all_dialects) - - dialect = dialects.find { |d| d.can_translate?(llm_model) } - raise DiscourseAi::Completions::Llm::UNKNOWN_MODEL if !dialect - - dialect - end - end - - def initialize(generic_prompt, llm_model, opts: {}) - @prompt = generic_prompt - @opts = opts - @llm_model = llm_model - end - - VALID_ID_REGEX = /\A[a-zA-Z0-9_]+\z/ - - def native_tool_support? - false - end - - def vision_support? - llm_model.vision_enabled? - end - - def tools - @tools ||= tools_dialect.translated_tools - end - - def tool_choice - prompt.tool_choice - end - - def self.no_more_tool_calls_text - # note, Anthropic must never prefill with an ending whitespace - "I WILL NOT USE TOOLS IN THIS REPLY, user expressed they wanted to stop using tool calls.\nHere is the best, complete, answer I can come up with given the information I have." - end - - def self.no_more_tool_calls_text_user - "DO NOT USE TOOLS IN YOUR REPLY. Return the best answer you can given the information I supplied you." - end - - def no_more_tool_calls_text - self.class.no_more_tool_calls_text - end - - def no_more_tool_calls_text_user - self.class.no_more_tool_calls_text_user - end - - def translate - messages = trim_messages(prompt.messages) - last_message = messages.last - inject_done_on_last_tool_call = false - - if !native_tool_support? && last_message && last_message[:type].to_sym == :tool && - prompt.tool_choice == :none - inject_done_on_last_tool_call = true - end - - translated = - messages - .map do |msg| - case msg[:type].to_sym - when :system - system_msg(msg) - when :user - user_msg(msg) - when :model - model_msg(msg) - when :tool - if inject_done_on_last_tool_call && msg == last_message - tools_dialect.inject_done { tool_msg(msg) } - else - tool_msg(msg) - end - when :tool_call - tool_call_msg(msg) - else - raise ArgumentError, "Unknown message type: #{msg[:type]}" - end - end - .compact - - translated - end - - def conversation_context - raise NotImplemented - end - - def max_prompt_tokens - raise NotImplemented - end - - attr_reader :prompt - - private - - attr_reader :opts, :llm_model - - def trim_messages(messages) - prompt_limit = max_prompt_tokens - current_token_count = 0 - message_step_size = (prompt_limit / 25).to_i * -1 - - trimmed_messages = [] - - range = (0..-1) - if messages.dig(0, :type) == :system - max_system_tokens = prompt_limit * 0.6 - system_message = messages[0] - system_size = calculate_message_token(system_message) - - if system_size > max_system_tokens - system_message[:content] = tokenizer.truncate( - system_message[:content], - max_system_tokens, - strict: SiteSetting.ai_strict_token_counting, - ) - end - - trimmed_messages << system_message - current_token_count += calculate_message_token(system_message) - range = (1..-1) - end - - reversed_trimmed_msgs = [] - - messages[range].reverse.each do |msg| - break if current_token_count >= prompt_limit - - message_tokens = calculate_message_token(msg) - - dupped_msg = msg.dup - - # Don't trim tool call metadata. - if msg[:type] == :tool_call - break if current_token_count + message_tokens + per_message_overhead > prompt_limit - - current_token_count += message_tokens + per_message_overhead - reversed_trimmed_msgs << dupped_msg - next - end - - # Trimming content to make sure we respect token limit. - while dupped_msg[:content].present? && - message_tokens + current_token_count + per_message_overhead > prompt_limit - dupped_msg[:content] = dupped_msg[:content][0..message_step_size] || "" - message_tokens = calculate_message_token(dupped_msg) - end - - next if dupped_msg[:content].blank? - - current_token_count += message_tokens + per_message_overhead - - reversed_trimmed_msgs << dupped_msg - end - - reversed_trimmed_msgs.pop if reversed_trimmed_msgs.last&.dig(:type) == :tool - - trimmed_messages.concat(reversed_trimmed_msgs.reverse) - end - - def per_message_overhead - 0 - end - - def calculate_message_token(msg) - llm_model.tokenizer_class.size(msg[:content].to_s) - end - - def tools_dialect - @tools_dialect ||= DiscourseAi::Completions::Dialects::XmlTools.new(prompt.tools) - end - - def system_msg(msg) - raise NotImplemented - end - - def model_msg(msg) - raise NotImplemented - end - - def user_msg(msg) - raise NotImplemented - end - - def tool_call_msg(msg) - new_content = tools_dialect.from_raw_tool_call(msg) - msg = msg.merge(content: new_content) - model_msg(msg) - end - - def tool_msg(msg) - new_content = tools_dialect.from_raw_tool(msg) - msg = msg.merge(content: new_content) - user_msg(msg) - end - - def to_encoded_content_array( - content:, - image_encoder:, - text_encoder:, - other_encoder: nil, - allow_vision: - ) - content = [content] if !content.is_a?(Array) - - current_string = +"" - result = [] - - content.each do |c| - if c.is_a?(String) - current_string << c - elsif c.is_a?(Hash) && c.key?(:upload_id) && allow_vision - if !current_string.empty? - result << text_encoder.call(current_string) - current_string = +"" - end - encoded = prompt.encode_upload(c[:upload_id]) - result << image_encoder.call(encoded) if encoded - elsif other_encoder - encoded = other_encoder.call(c) - result << encoded if encoded - end - end - - result << text_encoder.call(current_string) if !current_string.empty? - result - end - end - end - end -end diff --git a/lib/completions/dialects/fake.rb b/lib/completions/dialects/fake.rb deleted file mode 100644 index cda44110..00000000 --- a/lib/completions/dialects/fake.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class Fake < Dialect - class << self - def can_translate?(llm_model) - llm_model.provider == "fake" - end - end - - def tokenizer - DiscourseAi::Tokenizer::OpenAiTokenizer - end - - def translate - "" - end - end - end - end -end diff --git a/lib/completions/dialects/gemini.rb b/lib/completions/dialects/gemini.rb deleted file mode 100644 index 8ffbf5d7..00000000 --- a/lib/completions/dialects/gemini.rb +++ /dev/null @@ -1,163 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class Gemini < Dialect - class << self - def can_translate?(llm_model) - llm_model.provider == "google" - end - end - - def native_tool_support? - !llm_model.lookup_custom_param("disable_native_tools") - end - - def translate - # Gemini complains if we don't alternate model/user roles. - noop_model_response = { role: "model", parts: { text: "Ok." } } - messages = super - - interleving_messages = [] - previous_message = nil - - system_instruction = nil - - messages.each do |message| - if message[:role] == "system" - system_instruction = message[:content] - next - end - if previous_message - if (previous_message[:role] == "user" || previous_message[:role] == "function") && - message[:role] == "user" - interleving_messages << noop_model_response.dup - end - end - interleving_messages << message - previous_message = message - end - - { messages: interleving_messages, system_instruction: system_instruction } - end - - def tools - return if prompt.tools.blank? - - translated_tools = - prompt.tools.map do |t| - tool = { name: t.name, description: t.description } - tool[:parameters] = t.parameters_json_schema if t.parameters - tool - end - - [{ function_declarations: translated_tools }] - end - - def max_prompt_tokens - llm_model.max_prompt_tokens - end - - protected - - def calculate_message_token(context) - llm_model.tokenizer_class.size(context[:content].to_s + context[:name].to_s) - end - - def beta_api? - @beta_api ||= !llm_model.name.start_with?("gemini-1.0") - end - - def system_msg(msg) - content = msg[:content] - - if !native_tool_support? && tools_dialect.instructions.present? - content = content.to_s + "\n\n#{tools_dialect.instructions}" - end - - if beta_api? - { role: "system", content: content } - else - { role: "user", parts: { text: content } } - end - end - - def model_msg(msg) - if beta_api? - { role: "model", parts: [{ text: msg[:content] }] } - else - { role: "model", parts: { text: msg[:content] } } - end - end - - def user_msg(msg) - content_array = [] - content_array << "#{msg[:id]}: " if msg[:id] - - content_array << msg[:content] - content_array.flatten! - - content_array = - to_encoded_content_array( - content: content_array, - image_encoder: ->(details) { image_node(details) }, - text_encoder: ->(text) { { text: text } }, - allow_vision: vision_support? && beta_api?, - ) - - if beta_api? - { role: "user", parts: content_array } - else - { role: "user", parts: content_array.first } - end - end - - def image_node(details) - { inlineData: { mimeType: details[:mime_type], data: details[:base64] } } - end - - def tool_call_msg(msg) - if native_tool_support? - call_details = JSON.parse(msg[:content], symbolize_names: true) - part = { - functionCall: { - name: msg[:name] || call_details[:name], - args: call_details[:arguments], - }, - } - - if beta_api? - { role: "model", parts: [part] } - else - { role: "model", parts: part } - end - else - super - end - end - - def tool_msg(msg) - if native_tool_support? - part = { - functionResponse: { - name: msg[:name] || msg[:id], - response: { - content: msg[:content], - }, - }, - } - - if beta_api? - { role: "function", parts: [part] } - else - { role: "function", parts: part } - end - else - super - end - end - end - end - end -end diff --git a/lib/completions/dialects/mistral.rb b/lib/completions/dialects/mistral.rb deleted file mode 100644 index eaf5eab8..00000000 --- a/lib/completions/dialects/mistral.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -# basically the same as Open AI, except for no support for user names - -module DiscourseAi - module Completions - module Dialects - class Mistral < ChatGpt - class << self - def can_translate?(llm_model) - llm_model.provider == "mistral" - end - end - - def translate - corrected = super - corrected.each do |msg| - msg[:content] = "" if msg[:tool_calls] && msg[:role] == "assistant" - end - corrected - end - - private - - def user_msg(msg) - mapped = super - if name = mapped.delete(:name) - if mapped[:content].is_a?(String) - mapped[:content] = "#{name}: #{mapped[:content]}" - else - mapped[:content].each do |inner| - if inner[:text] - inner[:text] = "#{name}: #{inner[:text]}" - break - end - end - end - end - mapped - end - end - end - end -end diff --git a/lib/completions/dialects/nova.rb b/lib/completions/dialects/nova.rb deleted file mode 100644 index 10098c26..00000000 --- a/lib/completions/dialects/nova.rb +++ /dev/null @@ -1,178 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class Nova < Dialect - class << self - def can_translate?(llm_model) - llm_model.provider == "aws_bedrock" && llm_model.name.include?("amazon.nova") - end - end - - class NovaPrompt - attr_reader :system, :messages, :inference_config, :tool_config - - def initialize(system, messages, inference_config = nil, tool_config = nil) - @system = system - @messages = messages - @inference_config = inference_config - @tool_config = tool_config - end - - def system_prompt - # small hack for size estimation - system.to_s - end - - def has_tools? - tool_config.present? - end - - def to_payload(options = nil) - stop_sequences = options[:stop_sequences] - max_tokens = options[:max_tokens] - - inference_config = options&.slice(:temperature, :top_p, :top_k) - - inference_config[:stopSequences] = stop_sequences if stop_sequences.present? - - inference_config[:max_new_tokens] = max_tokens if max_tokens.present? - - result = { system: system, messages: messages } - result[:inferenceConfig] = inference_config if inference_config.present? - result[:toolConfig] = tool_config if tool_config.present? - result[:response_format] = { type: "json_object" } if options[:response_format].present? - - result - end - end - - def translate - messages = super - - system = messages.shift[:content] if messages.first&.dig(:role) == "system" - nova_messages = messages.map { |msg| { role: msg[:role], content: build_content(msg) } } - - inference_config = build_inference_config - tool_config = tools_dialect.translated_tools if native_tool_support? - - NovaPrompt.new( - system.presence && [{ text: system }], - nova_messages, - inference_config, - tool_config, - ) - end - - def max_prompt_tokens - llm_model.max_prompt_tokens - end - - def native_tool_support? - !llm_model.lookup_custom_param("disable_native_tools") - end - - def tools_dialect - if native_tool_support? - @tools_dialect ||= DiscourseAi::Completions::Dialects::NovaTools.new(prompt.tools) - else - super - end - end - - private - - def build_content(msg) - content = [] - - existing_content = msg[:content] - - if existing_content.is_a?(Hash) - content << existing_content - elsif existing_content.is_a?(String) - content << { text: existing_content } - end - - msg[:images]&.each { |image| content << image } - - content - end - - def build_inference_config - return unless opts[:inference_config] - - config = {} - ic = opts[:inference_config] - - config[:max_new_tokens] = ic[:max_new_tokens] if ic[:max_new_tokens] - config[:temperature] = ic[:temperature] if ic[:temperature] - config[:top_p] = ic[:top_p] if ic[:top_p] - config[:top_k] = ic[:top_k] if ic[:top_k] - config[:stopSequences] = ic[:stop_sequences] if ic[:stop_sequences] - - config.present? ? config : nil - end - - def detect_format(mime_type) - case mime_type - when "image/jpeg" - "jpeg" - when "image/png" - "png" - when "image/gif" - "gif" - when "image/webp" - "webp" - else - "jpeg" # default - end - end - - def system_msg(msg) - msg = { role: "system", content: msg[:content] } - - if tools_dialect.instructions.present? - msg[:content] = msg[:content].dup << "\n\n#{tools_dialect.instructions}" - end - - msg - end - - def user_msg(msg) - images = nil - if vision_support? - encoded_uploads = prompt.encoded_uploads(msg) - encoded_uploads&.each do |upload| - images ||= [] - images << { - image: { - format: upload[:format] || detect_format(upload[:mime_type]), - source: { - bytes: upload[:base64], - }, - }, - } - end - end - - { role: "user", content: DiscourseAi::Completions::Prompt.text_only(msg), images: images } - end - - def model_msg(msg) - { role: "assistant", content: msg[:content] } - end - - def tool_msg(msg) - translated = tools_dialect.from_raw_tool(msg) - { role: "user", content: translated } - end - - def tool_call_msg(msg) - translated = tools_dialect.from_raw_tool_call(msg) - { role: "assistant", content: translated } - end - end - end - end -end diff --git a/lib/completions/dialects/nova_tools.rb b/lib/completions/dialects/nova_tools.rb deleted file mode 100644 index 54d009a6..00000000 --- a/lib/completions/dialects/nova_tools.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class NovaTools - def initialize(tools) - @raw_tools = tools - end - - def translated_tools - return if !@raw_tools.present? - - # note: forced tools are not supported yet toolChoice is always auto - { - tools: - @raw_tools.map do |tool| - { - toolSpec: { - name: tool.name, - description: tool.description, - inputSchema: { - json: tool.parameters_json_schema, - }, - }, - } - end, - } - end - - # nativ tools require no system instructions - def instructions - "" - end - - def from_raw_tool_call(raw_message) - { - toolUse: { - toolUseId: raw_message[:id], - name: raw_message[:name], - input: JSON.parse(raw_message[:content])["arguments"], - }, - } - end - - def from_raw_tool(raw_message) - { - toolResult: { - toolUseId: raw_message[:id], - content: [{ json: JSON.parse(raw_message[:content]) }], - }, - } - end - end - end - end -end diff --git a/lib/completions/dialects/ollama.rb b/lib/completions/dialects/ollama.rb deleted file mode 100644 index 60d58455..00000000 --- a/lib/completions/dialects/ollama.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class Ollama < Dialect - class << self - def can_translate?(llm_model) - llm_model.provider == "ollama" - end - end - - def native_tool_support? - enable_native_tool? - end - - def max_prompt_tokens - llm_model.max_prompt_tokens - end - - private - - def tools_dialect - if enable_native_tool? - @tools_dialect ||= DiscourseAi::Completions::Dialects::OllamaTools.new(prompt.tools) - else - super - end - end - - def tokenizer - llm_model.tokenizer_class - end - - def model_msg(msg) - { role: "assistant", content: msg[:content] } - end - - def tool_call_msg(msg) - if enable_native_tool? - tools_dialect.from_raw_tool_call(msg) - else - super - end - end - - def tool_msg(msg) - if enable_native_tool? - tools_dialect.from_raw_tool(msg) - else - super - end - end - - def system_msg(msg) - msg = { role: "system", content: msg[:content] } - - if tools_dialect.instructions.present? - msg[:content] = msg[:content].dup << "\n\n#{tools_dialect.instructions}" - end - - msg - end - - def enable_native_tool? - return @enable_native_tool if defined?(@enable_native_tool) - - @enable_native_tool = llm_model.lookup_custom_param("enable_native_tool") - end - - def user_msg(msg) - user_message = { role: "user", content: DiscourseAi::Completions::Prompt.text_only(msg) } - - encoded_uploads = prompt.encoded_uploads(msg) - if encoded_uploads.present? - images = - encoded_uploads - .map do |upload| - if upload[:mime_type].start_with?("image/") - upload[:base64] - else - nil - end - end - .compact - - user_message[:images] = images if images.present? - end - - # TODO: Add support for user messages with embedded user ids - - user_message - end - end - end - end -end diff --git a/lib/completions/dialects/ollama_tools.rb b/lib/completions/dialects/ollama_tools.rb deleted file mode 100644 index 90cdb136..00000000 --- a/lib/completions/dialects/ollama_tools.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - # TODO: Define the Tool class to be inherited by all tools. - class OllamaTools - def initialize(tools) - @raw_tools = tools - end - - def instructions - "" # Noop. Tools are listed separate. - end - - def translated_tools - raw_tools.map do |tool| - { - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters_json_schema, - }, - } - end - end - - def from_raw_tool_call(raw_message) - call_details = JSON.parse(raw_message[:content], symbolize_names: true) - call_details[:name] = raw_message[:name] - - { - role: "assistant", - content: nil, - tool_calls: [{ type: "function", function: call_details }], - } - end - - def from_raw_tool(raw_message) - { role: "tool", content: raw_message[:content], name: raw_message[:name] } - end - - private - - attr_reader :raw_tools - end - end - end -end diff --git a/lib/completions/dialects/open_ai_compatible.rb b/lib/completions/dialects/open_ai_compatible.rb deleted file mode 100644 index d6519822..00000000 --- a/lib/completions/dialects/open_ai_compatible.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class OpenAiCompatible < ChatGpt - class << self - def can_translate?(_llm_model) - # fallback dialect - true - end - end - - def tokenizer - llm_model&.tokenizer_class || DiscourseAi::Tokenizer::Llama3Tokenizer - end - - def tools - @tools ||= tools_dialect.translated_tools - end - - def max_prompt_tokens - return llm_model.max_prompt_tokens if llm_model&.max_prompt_tokens - - 32_000 - end - - def translate - translated = super - - return translated unless llm_model.lookup_custom_param("disable_system_prompt") - - system_msg, user_msg = translated.shift(2) - - if user_msg[:content].is_a?(Array) # Has inline images. - user_msg[:content].first[:text] = [ - system_msg[:content], - user_msg[:content].first[:text], - ].join("\n") - else - user_msg[:content] = [system_msg[:content], user_msg[:content]].join("\n") - end - - translated.unshift(user_msg) - end - end - end - end -end diff --git a/lib/completions/dialects/open_ai_tools.rb b/lib/completions/dialects/open_ai_tools.rb deleted file mode 100644 index 3411dbfe..00000000 --- a/lib/completions/dialects/open_ai_tools.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class OpenAiTools - def initialize(tools, responses_api: false) - @responses_api = responses_api - @raw_tools = tools - end - - def translated_tools - if @responses_api - raw_tools.map do |tool| - { - type: "function", - name: tool.name, - description: tool.description, - parameters: tool.parameters_json_schema, - } - end - else - raw_tools.map do |tool| - { - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters_json_schema, - }, - } - end - end - end - - def instructions - "" # Noop. Tools are listed separate. - end - - def from_raw_tool_call(raw_message) - call_details = JSON.parse(raw_message[:content], symbolize_names: true) - call_details[:arguments] = call_details[:arguments].to_json - call_details[:name] = raw_message[:name] - - if @responses_api - { - type: "function_call", - call_id: raw_message[:id], - name: call_details[:name], - arguments: call_details[:arguments], - } - else - { - role: "assistant", - content: nil, - tool_calls: [{ type: "function", function: call_details, id: raw_message[:id] }], - } - end - end - - def from_raw_tool(raw_message) - if @responses_api - { - type: "function_call_output", - call_id: raw_message[:id], - output: raw_message[:content], - } - else - { - role: "tool", - tool_call_id: raw_message[:id], - content: raw_message[:content], - name: raw_message[:name], - } - end - end - - private - - attr_reader :raw_tools - end - end - end -end diff --git a/lib/completions/dialects/xml_tools.rb b/lib/completions/dialects/xml_tools.rb deleted file mode 100644 index 7af04df1..00000000 --- a/lib/completions/dialects/xml_tools.rb +++ /dev/null @@ -1,149 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Dialects - class XmlTools - def initialize(tools) - @raw_tools = tools - end - - def translated_tools - result = +"" - - raw_tools.each do |tool| - parameters = +"" - if tool.parameters.present? - tool.parameters.each do |parameter| - parameters << <<~PARAMETER - <parameter> - <name>#{parameter.name}</name> - <type>#{parameter.type}</type> - <description>#{parameter.description}</description> - <required>#{parameter.required}</required> - PARAMETER - if parameter.item_type - parameters << "<array_item_type>#{parameter.item_type}</array_item_type>\n" - end - parameters << "<options>#{parameter.enum.join(",")}</options>\n" if parameter.enum - parameters << "</parameter>\n" - end - end - - result << <<~TOOLS - <tool_description> - <tool_name>#{tool.name}</tool_name> - <description>#{tool.description}</description> - <parameters> - #{parameters}</parameters> - </tool_description> - TOOLS - end - result - end - - def instructions - return "" if raw_tools.blank? - - @instructions ||= - begin - has_arrays = raw_tools.any? { |tool| tool.parameters&.any? { |p| p.type == "array" } } - - (<<~TEXT).strip - #{tool_preamble(include_array_tip: has_arrays)} - <tools> - #{translated_tools}</tools> - TEXT - end - end - - DONE_MESSAGE = - "Regardless of what you think, REPLY IMMEDIATELY, WITHOUT MAKING ANY FURTHER TOOL CALLS, YOU ARE OUT OF TOOL CALL QUOTA!" - - def from_raw_tool(raw_message) - result = (<<~TEXT).strip - <function_results> - <result> - <tool_name>#{raw_message[:name] || raw_message[:id]}</tool_name> - <json> - #{raw_message[:content]} - </json> - </result> - </function_results> - TEXT - - if @injecting_done - "#{result}\n\n#{DONE_MESSAGE}" - else - result - end - end - - def from_raw_tool_call(raw_message) - parsed = JSON.parse(raw_message[:content], symbolize_names: true) - parameters = +"" - - if parsed[:arguments] - parameters << "<parameters>\n" - parsed[:arguments].each { |k, v| parameters << "<#{k}>#{v}</#{k}>\n" } - parameters << "</parameters>\n" - end - - (<<~TEXT).strip - <function_calls> - <invoke> - <tool_name>#{raw_message[:name] || parsed[:name]}</tool_name> - #{parameters}</invoke> - </function_calls> - TEXT - end - - def inject_done(&blk) - @injecting_done = true - blk.call - ensure - @injecting_done = false - end - - private - - attr_reader :raw_tools - - def tool_preamble(include_array_tip: true) - array_tip = - if include_array_tip - <<~TEXT - If a parameter type is an array, return an array of values. For example: - <$PARAMETER_NAME>["one","two","three"]</$PARAMETER_NAME> - TEXT - else - "" - end - - <<~TEXT - In this environment you have access to a set of tools you can use to answer the user's question. - You may call them like this. - - <function_calls> - <invoke> - <tool_name>$TOOL_NAME</tool_name> - <parameters> - <$PARAMETER_NAME>$PARAMETER_VALUE</$PARAMETER_NAME> - ... - </parameters> - </invoke> - </function_calls> - #{array_tip} - If you wish to call multiple function in one reply, wrap multiple <invoke> - block in a single <function_calls> block. - - - Always prefer to lead with tool calls, if you need to execute any. - - Avoid all niceties prior to tool calls, Eg: "Let me look this up for you.." etc. - - DO NOT encode HTML entities in tool calls. You may use <![CDATA[...]]> for encoding if required. - Here are the complete list of tools available: - TEXT - end - end - end - end -end diff --git a/lib/completions/endpoints/anthropic.rb b/lib/completions/endpoints/anthropic.rb deleted file mode 100644 index 0ca940c5..00000000 --- a/lib/completions/endpoints/anthropic.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class Anthropic < Base - def self.can_contact?(model_provider) - model_provider == "anthropic" - end - - def normalize_model_params(model_params) - # max_tokens, temperature, stop_sequences are already supported - model_params = model_params.dup - model_params.delete(:top_p) if llm_model.lookup_custom_param("disable_top_p") - model_params.delete(:temperature) if llm_model.lookup_custom_param("disable_temperature") - model_params - end - - def default_options(dialect) - mapped_model = - case llm_model.name - when "claude-2" - "claude-2.1" - when "claude-instant-1" - "claude-instant-1.2" - when "claude-3-haiku" - "claude-3-haiku-20240307" - when "claude-3-sonnet" - "claude-3-sonnet-20240229" - when "claude-3-opus" - "claude-3-opus-20240229" - when "claude-3-5-sonnet" - "claude-3-5-sonnet-latest" - when "claude-3-7-sonnet" - "claude-3-7-sonnet-latest" - when "claude-4-opus" - "claude-4-opus-20250514" - when "claude-4-sonnet" - "claude-4-sonnet-20250514" - else - llm_model.name - end - - # Note: Anthropic requires this param - max_tokens = 4096 - # 3.5 and 3.7 models have a higher token limit - max_tokens = 8192 if mapped_model.match?(/3.[57]/) - - options = { model: mapped_model, max_tokens: max_tokens } - - # reasoning has even higher token limits - if llm_model.lookup_custom_param("enable_reasoning") - reasoning_tokens = - llm_model.lookup_custom_param("reasoning_tokens").to_i.clamp(1024, 32_768) - - # this allows for lots of tokens beyond reasoning - options[:max_tokens] = reasoning_tokens + 30_000 - options[:thinking] = { type: "enabled", budget_tokens: reasoning_tokens } - end - - options[:stop_sequences] = ["</function_calls>"] if !dialect.native_tool_support? && - dialect.prompt.has_tools? - - options - end - - def provider_id - AiApiAuditLog::Provider::Anthropic - end - - private - - def xml_tags_to_strip(dialect) - if dialect.prompt.has_tools? - %w[thinking search_quality_reflection search_quality_score] - else - [] - end - end - - # this is an approximation, we will update it later if request goes through - def prompt_size(prompt) - tokenizer.size(prompt.system_prompt.to_s + " " + prompt.messages.to_s) - end - - def model_uri - URI(llm_model.url) - end - - def xml_tools_enabled? - !@native_tool_support - end - - def prepare_payload(prompt, model_params, dialect) - @native_tool_support = dialect.native_tool_support? - - payload = - default_options(dialect).merge(model_params.except(:response_format)).merge( - messages: prompt.messages, - ) - payload[:system] = prompt.system_prompt if prompt.system_prompt.present? - payload[:stream] = true if @streaming_mode - - prefilled_message = +"" - - if prompt.has_tools? - payload[:tools] = prompt.tools - if dialect.tool_choice.present? - if dialect.tool_choice == :none - payload[:tool_choice] = { type: "none" } - - # prefill prompt to nudge LLM to generate a response that is useful. - # without this LLM (even 3.7) can get confused and start text preambles for a tool calls. - prefilled_message << dialect.no_more_tool_calls_text - else - payload[:tool_choice] = { type: "tool", name: prompt.tool_choice } - end - end - end - - # Prefill prompt to force JSON output. - if model_params[:response_format].present? - prefilled_message << " " if !prefilled_message.empty? - prefilled_message << "{" - @forced_json_through_prefill = true - end - - if !prefilled_message.empty? - payload[:messages] << { role: "assistant", content: prefilled_message } - end - - payload - end - - def prepare_request(payload) - headers = { - "anthropic-version" => "2023-06-01", - "x-api-key" => llm_model.api_key, - "content-type" => "application/json", - } - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def decode_chunk(partial_data) - @decoder ||= JsonStreamDecoder.new - (@decoder << partial_data) - .map { |parsed_json| processor.process_streamed_message(parsed_json) } - .compact - end - - def decode(response_data) - processor.process_message(response_data) - end - - def processor - @processor ||= - DiscourseAi::Completions::AnthropicMessageProcessor.new( - streaming_mode: @streaming_mode, - partial_tool_calls: partial_tool_calls, - output_thinking: output_thinking, - ) - end - - def has_tool?(_response_data) - processor.tool_calls.present? - end - - def tool_calls - processor.to_tool_calls - end - - def final_log_update(log) - log.request_tokens = processor.input_tokens if processor.input_tokens - log.response_tokens = processor.output_tokens if processor.output_tokens - end - end - end - end -end diff --git a/lib/completions/endpoints/aws_bedrock.rb b/lib/completions/endpoints/aws_bedrock.rb deleted file mode 100644 index f1344f3a..00000000 --- a/lib/completions/endpoints/aws_bedrock.rb +++ /dev/null @@ -1,266 +0,0 @@ -# frozen_string_literal: true - -require "aws-sigv4" - -module DiscourseAi - module Completions - module Endpoints - class AwsBedrock < Base - attr_reader :dialect - - def self.can_contact?(model_provider) - model_provider == "aws_bedrock" - end - - def normalize_model_params(model_params) - model_params = model_params.dup - - # max_tokens, temperature, stop_sequences, top_p are already supported - # - model_params.delete(:top_p) if llm_model.lookup_custom_param("disable_top_p") - model_params.delete(:temperature) if llm_model.lookup_custom_param("disable_temperature") - - model_params - end - - def default_options(dialect) - options = - if dialect.is_a?(DiscourseAi::Completions::Dialects::Claude) - max_tokens = 4096 - max_tokens = 8192 if bedrock_model_id.match?(/3.[57]/) - - result = { anthropic_version: "bedrock-2023-05-31" } - if llm_model.lookup_custom_param("enable_reasoning") - # we require special headers to go over 64k output tokens, lets - # wait for feature requests before enabling this - reasoning_tokens = - llm_model.lookup_custom_param("reasoning_tokens").to_i.clamp(1024, 32_768) - - # this allows for ample tokens beyond reasoning - max_tokens = reasoning_tokens + 30_000 - result[:thinking] = { type: "enabled", budget_tokens: reasoning_tokens } - end - result[:max_tokens] = max_tokens - - result - else - {} - end - - options[:stop_sequences] = ["</function_calls>"] if !dialect.native_tool_support? && - dialect.prompt.has_tools? - options - end - - def provider_id - AiApiAuditLog::Provider::Anthropic - end - - def xml_tags_to_strip(dialect) - if dialect.prompt.has_tools? - %w[thinking search_quality_reflection search_quality_score] - else - [] - end - end - - private - - def bedrock_model_id - case llm_model.name - when "claude-2" - "anthropic.claude-v2:1" - when "claude-3-haiku" - "anthropic.claude-3-haiku-20240307-v1:0" - when "claude-3-sonnet" - "anthropic.claude-3-sonnet-20240229-v1:0" - when "claude-instant-1" - "anthropic.claude-instant-v1" - when "claude-3-opus" - "anthropic.claude-3-opus-20240229-v1:0" - when "claude-3-5-sonnet" - "anthropic.claude-3-5-sonnet-20241022-v2:0" - when "claude-3-5-haiku" - "anthropic.claude-3-5-haiku-20241022-v1:0" - when "claude-3-7-sonnet" - "anthropic.claude-3-7-sonnet-20250219-v1:0" - else - llm_model.name - end - end - - def prompt_size(prompt) - # approximation - tokenizer.size(prompt.system_prompt.to_s + " " + prompt.messages.to_s) - end - - def model_uri - region = llm_model.lookup_custom_param("region") - - if region.blank? || bedrock_model_id.blank? - raise CompletionFailed.new(I18n.t("discourse_ai.llm_models.bedrock_invalid_url")) - end - - api_url = - "https://bedrock-runtime.#{region}.amazonaws.com/model/#{bedrock_model_id}/invoke" - - api_url = @streaming_mode ? (api_url + "-with-response-stream") : api_url - - URI(api_url) - end - - def prepare_payload(prompt, model_params, dialect) - @native_tool_support = dialect.native_tool_support? - @dialect = dialect - - payload = nil - - if dialect.is_a?(DiscourseAi::Completions::Dialects::Claude) - payload = - default_options(dialect).merge(model_params.except(:response_format)).merge( - messages: prompt.messages, - ) - - payload[:system] = prompt.system_prompt if prompt.system_prompt.present? - - prefilled_message = +"" - - if prompt.has_tools? - payload[:tools] = prompt.tools - if dialect.tool_choice.present? - if dialect.tool_choice == :none - # not supported on bedrock as of 2025-03-24 - # retest in 6 months - # payload[:tool_choice] = { type: "none" } - - # prefill prompt to nudge LLM to generate a response that is useful, instead of trying to call a tool - prefilled_message << dialect.no_more_tool_calls_text - else - payload[:tool_choice] = { type: "tool", name: prompt.tool_choice } - end - end - end - - # Prefill prompt to force JSON output. - if model_params[:response_format].present? - prefilled_message << " " if !prefilled_message.empty? - prefilled_message << "{" - @forced_json_through_prefill = true - end - - if !prefilled_message.empty? - payload[:messages] << { role: "assistant", content: prefilled_message } - end - elsif dialect.is_a?(DiscourseAi::Completions::Dialects::Nova) - payload = prompt.to_payload(default_options(dialect).merge(model_params)) - else - raise "Unsupported dialect" - end - payload - end - - def prepare_request(payload) - headers = { "content-type" => "application/json", "Accept" => "*/*" } - - signer = - Aws::Sigv4::Signer.new( - access_key_id: llm_model.lookup_custom_param("access_key_id"), - region: llm_model.lookup_custom_param("region"), - secret_access_key: llm_model.api_key, - service: "bedrock", - ) - - Net::HTTP::Post - .new(model_uri) - .tap do |r| - r.body = payload - - signed_request = - signer.sign_request(req: r, http_method: r.method, url: model_uri, body: r.body) - - r.initialize_http_header(headers.merge(signed_request.headers)) - end - end - - def decode_chunk(partial_data) - bedrock_decode(partial_data) - .map do |decoded_partial_data| - @raw_response ||= +"" - @raw_response << decoded_partial_data - @raw_response << "\n" - - parsed_json = JSON.parse(decoded_partial_data, symbolize_names: true) - processor.process_streamed_message(parsed_json) - end - .compact - end - - def decode(response_data) - processor.process_message(response_data) - end - - def bedrock_decode(chunk) - @decoder ||= Aws::EventStream::Decoder.new - - decoded, _done = @decoder.decode_chunk(chunk) - - messages = [] - return messages if !decoded - - i = 0 - while decoded - parsed = JSON.parse(decoded.payload.string) - if exception = decoded.headers[":exception-type"] - Rails.logger.error("#{self.class.name}: #{exception}: #{parsed}") - # TODO based on how often this happens, we may want to raise so we - # can retry, this may catch rate limits for example - end - # perhaps some control message we can just ignore - messages << Base64.decode64(parsed["bytes"]) if parsed && parsed["bytes"] - - decoded, _done = @decoder.decode_chunk - - i += 1 - if i > 10_000 - Rails.logger.error( - "DiscourseAI: Stream decoder looped too many times, logic error needs fixing", - ) - break - end - end - - messages - rescue JSON::ParserError, - Aws::EventStream::Errors::MessageChecksumError, - Aws::EventStream::Errors::PreludeChecksumError => e - Rails.logger.error("#{self.class.name}: #{e.message}") - [] - end - - def final_log_update(log) - log.request_tokens = processor.input_tokens if processor.input_tokens - log.response_tokens = processor.output_tokens if processor.output_tokens - log.raw_response_payload = @raw_response if @raw_response - end - - def processor - if dialect.is_a?(DiscourseAi::Completions::Dialects::Claude) - @processor ||= - DiscourseAi::Completions::AnthropicMessageProcessor.new( - streaming_mode: @streaming_mode, - partial_tool_calls: partial_tool_calls, - output_thinking: output_thinking, - ) - else - @processor ||= - DiscourseAi::Completions::NovaMessageProcessor.new(streaming_mode: @streaming_mode) - end - end - - def xml_tools_enabled? - !@native_tool_support - end - end - end - end -end diff --git a/lib/completions/endpoints/base.rb b/lib/completions/endpoints/base.rb deleted file mode 100644 index abacdeec..00000000 --- a/lib/completions/endpoints/base.rb +++ /dev/null @@ -1,473 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class Base - attr_reader :partial_tool_calls, :output_thinking - - CompletionFailed = Class.new(StandardError) - # 6 minutes - # Reasoning LLMs can take a very long time to respond, generally it will be under 5 minutes - # The alternative is to have per LLM timeouts but that would make it extra confusing for people - # configuring. Let's try this simple solution first. - TIMEOUT = 360 - - class << self - def endpoint_for(provider_name) - endpoints = [ - DiscourseAi::Completions::Endpoints::AwsBedrock, - DiscourseAi::Completions::Endpoints::OpenAi, - DiscourseAi::Completions::Endpoints::HuggingFace, - DiscourseAi::Completions::Endpoints::Gemini, - DiscourseAi::Completions::Endpoints::Vllm, - DiscourseAi::Completions::Endpoints::Anthropic, - DiscourseAi::Completions::Endpoints::Cohere, - DiscourseAi::Completions::Endpoints::SambaNova, - DiscourseAi::Completions::Endpoints::Mistral, - DiscourseAi::Completions::Endpoints::OpenRouter, - ] - - endpoints << DiscourseAi::Completions::Endpoints::Ollama if !Rails.env.production? - - if Rails.env.test? || Rails.env.development? - endpoints << DiscourseAi::Completions::Endpoints::Fake - end - - endpoints.detect(-> { raise DiscourseAi::Completions::Llm::UNKNOWN_MODEL }) do |ek| - ek.can_contact?(provider_name) - end - end - - def can_contact?(_model_provider) - raise NotImplementedError - end - end - - def initialize(llm_model) - @llm_model = llm_model - end - - def enforce_max_output_tokens(value) - if @llm_model.max_output_tokens.to_i > 0 - value = @llm_model.max_output_tokens if (value.to_i > @llm_model.max_output_tokens) || - (value.to_i <= 0) - end - value - end - - def use_ssl? - if model_uri&.scheme.present? - model_uri.scheme == "https" - else - true - end - end - - def xml_tags_to_strip(dialect) - [] - end - - def perform_completion!( - dialect, - user, - model_params = {}, - feature_name: nil, - feature_context: nil, - partial_tool_calls: false, - output_thinking: false, - cancel_manager: nil, - &blk - ) - LlmQuota.check_quotas!(@llm_model, user) - start_time = Time.now - - if cancel_manager && cancel_manager.cancelled? - # nothing to do - return - end - - @forced_json_through_prefill = false - @partial_tool_calls = partial_tool_calls - @output_thinking = output_thinking - - max_tokens = enforce_max_output_tokens(model_params[:max_tokens]) - model_params[:max_tokens] = max_tokens if max_tokens - model_params = normalize_model_params(model_params) - orig_blk = blk - - if block_given? && disable_streaming? - result = - perform_completion!( - dialect, - user, - model_params, - feature_name: feature_name, - feature_context: feature_context, - partial_tool_calls: partial_tool_calls, - output_thinking: output_thinking, - cancel_manager: cancel_manager, - ) - - wrapped = result - wrapped = [result] if !result.is_a?(Array) - wrapped.each do |partial| - blk.call(partial) - break cancel_manager&.cancelled? - end - return result - end - - @streaming_mode = block_given? - - prompt = dialect.translate - - structured_output = nil - - if model_params[:response_format].present? - schema_properties = - model_params[:response_format].dig(:json_schema, :schema, :properties) - - if schema_properties.present? - structured_output = DiscourseAi::Completions::StructuredOutput.new(schema_properties) - end - end - - cancel_manager_callback = nil - cancelled = false - - FinalDestination::HTTP.start( - model_uri.host, - model_uri.port, - use_ssl: use_ssl?, - read_timeout: TIMEOUT, - open_timeout: TIMEOUT, - write_timeout: TIMEOUT, - ) do |http| - if cancel_manager - cancel_manager_callback = - lambda do - cancelled = true - http.finish - end - cancel_manager.add_callback(cancel_manager_callback) - end - response_data = +"" - response_raw = +"" - - # Needed to response token calculations. Cannot rely on response_data due to function buffering. - partials_raw = +"" - request_body = prepare_payload(prompt, model_params, dialect).to_json - - request = prepare_request(request_body) - - # Some providers rely on prefill to return structured outputs, so the start - # of the JSON won't be included in the response. Supply it to keep JSON valid. - structured_output << +"{" if structured_output && @forced_json_through_prefill - - http.request(request) do |response| - if response.code.to_i != 200 - Rails.logger.error( - "#{self.class.name}: status: #{response.code.to_i} - body: #{response.body}", - ) - raise CompletionFailed, response.body - end - - xml_tool_processor = - XmlToolProcessor.new( - partial_tool_calls: partial_tool_calls, - tool_definitions: dialect.prompt.tools, - ) if xml_tools_enabled? && dialect.prompt.has_tools? - - to_strip = xml_tags_to_strip(dialect) - xml_stripper = - DiscourseAi::Completions::XmlTagStripper.new(to_strip) if to_strip.present? - - if @streaming_mode - blk = - lambda do |partial| - if partial.is_a?(String) - partial = xml_stripper << partial if xml_stripper && !partial.empty? - - if structured_output.present? - structured_output << partial if !partial.empty? - partial = structured_output - end - end - orig_blk.call(partial) if partial - end - end - - log = - start_log( - provider_id: provider_id, - request_body: request_body, - dialect: dialect, - prompt: prompt, - user: user, - feature_name: feature_name, - feature_context: feature_context, - ) - - if !@streaming_mode - response_data = - non_streaming_response( - response: response, - xml_tool_processor: xml_tool_processor, - xml_stripper: xml_stripper, - partials_raw: partials_raw, - response_raw: response_raw, - structured_output: structured_output, - ) - return response_data - end - - begin - response.read_body do |chunk| - break if cancelled - - response_raw << chunk - - decode_chunk(chunk).each do |partial| - break if cancelled - partials_raw << partial.to_s - response_data << partial if partial.is_a?(String) - partials = [partial] - if xml_tool_processor && partial.is_a?(String) - partials = (xml_tool_processor << partial) - break if xml_tool_processor.should_cancel? - end - partials.each { |inner_partial| blk.call(inner_partial) } - end - end - end - if xml_stripper - stripped = xml_stripper.finish - if stripped.present? - response_data << stripped - result = [] - result = (xml_tool_processor << stripped) if xml_tool_processor - result.each { |partial| blk.call(partial) } - end - end - xml_tool_processor.finish.each { |partial| blk.call(partial) } if xml_tool_processor - decode_chunk_finish.each { |partial| blk.call(partial) } - - if structured_output - structured_output.finish - if structured_output.broken? - # signal last partial output which will get parsed - # by best effort json parser - blk.call("") - end - end - return response_data - ensure - if log - log.raw_response_payload = response_raw - final_log_update(log) - log.response_tokens = tokenizer.size(partials_raw) if log.response_tokens.blank? - log.created_at = start_time - log.updated_at = Time.now - log.duration_msecs = (Time.now - start_time) * 1000 - log.save! - LlmQuota.log_usage(@llm_model, user, log.request_tokens, log.response_tokens) - if Rails.env.development? && !ENV["DISCOURSE_AI_NO_DEBUG"] - puts "#{self.class.name}: request_tokens #{log.request_tokens} response_tokens #{log.response_tokens}" - end - end - if log && (logger = Thread.current[:llm_audit_log]) - call_data = <<~LOG - #{self.class.name}: request_tokens #{log.request_tokens} response_tokens #{log.response_tokens} - request: - #{format_possible_json_payload(log.raw_request_payload)} - response: - #{response_data} - LOG - logger.info(call_data) - end - if log && (structured_logger = Thread.current[:llm_audit_structured_log]) - llm_request = - begin - JSON.parse(log.raw_request_payload) - rescue StandardError - log.raw_request_payload - end - - # gemini puts passwords in query params - # we don't want to log that - structured_logger.log( - "llm_call", - args: { - class: self.class.name, - completion_url: request.uri.to_s.split("?")[0], - request: llm_request, - result: response_data, - request_tokens: log.request_tokens, - response_tokens: log.response_tokens, - duration: log.duration_msecs, - stream: @streaming_mode, - }, - start_time: start_time.utc, - end_time: Time.now.utc, - ) - end - end - end - rescue IOError, StandardError - raise if !cancelled - ensure - if cancel_manager && cancel_manager_callback - cancel_manager.remove_callback(cancel_manager_callback) - end - end - - def final_log_update(log) - # for people that need to override - end - - def default_options - raise NotImplementedError - end - - def provider_id - raise NotImplementedError - end - - def prompt_size(prompt) - tokenizer.size(extract_prompt_for_tokenizer(prompt)) - end - - attr_reader :llm_model - - protected - - def tokenizer - llm_model.tokenizer_class - end - - # should normalize temperature, max_tokens, stop_words to endpoint specific values - def normalize_model_params(model_params) - raise NotImplementedError - end - - def model_uri - raise NotImplementedError - end - - def prepare_payload(_prompt, _model_params) - raise NotImplementedError - end - - def prepare_request(_payload) - raise NotImplementedError - end - - def decode(_response_raw) - raise NotImplementedError - end - - def decode_chunk_finish - [] - end - - def decode_chunk(_chunk) - raise NotImplementedError - end - - def extract_prompt_for_tokenizer(prompt) - prompt.map { |message| message[:content] || message["content"] || "" }.join("\n") - end - - def xml_tools_enabled? - raise NotImplementedError - end - - def disable_streaming? - @disable_streaming = !!llm_model.lookup_custom_param("disable_streaming") - end - - private - - def format_possible_json_payload(payload) - begin - JSON.pretty_generate(JSON.parse(payload)) - rescue JSON::ParserError - payload - end - end - - def start_log( - provider_id:, - request_body:, - dialect:, - prompt:, - user:, - feature_name:, - feature_context: - ) - AiApiAuditLog.new( - provider_id: provider_id, - user_id: user&.id, - raw_request_payload: request_body, - request_tokens: prompt_size(prompt), - topic_id: dialect.prompt.topic_id, - post_id: dialect.prompt.post_id, - feature_name: feature_name, - language_model: llm_model.name, - feature_context: feature_context.present? ? feature_context.as_json : nil, - ) - end - - def non_streaming_response( - response:, - xml_tool_processor:, - xml_stripper:, - partials_raw:, - response_raw:, - structured_output: - ) - response_raw << response.read_body - response_data = decode(response_raw) - - response_data.each { |partial| partials_raw << partial.to_s } - - if xml_tool_processor - response_data.each do |partial| - processed = (xml_tool_processor << partial) - processed << xml_tool_processor.finish - response_data = [] - processed.flatten.compact.each { |inner| response_data << inner } - end - end - - if xml_stripper - response_data.map! do |partial| - stripped = (xml_stripper << partial) if partial.is_a?(String) - if stripped.present? - stripped - else - partial - end - end - response_data << xml_stripper.finish - end - - response_data.reject!(&:blank?) - - if structured_output.present? - response_data.each { |data| structured_output << data if data.is_a?(String) } - structured_output.finish - - return structured_output - end - - # this is to keep stuff backwards compatible - response_data = response_data.first if response_data.length == 1 - - response_data - end - end - end - end -end diff --git a/lib/completions/endpoints/canned_response.rb b/lib/completions/endpoints/canned_response.rb deleted file mode 100644 index 9f2b10c3..00000000 --- a/lib/completions/endpoints/canned_response.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class CannedResponse - CANNED_RESPONSE_ERROR = Class.new(StandardError) - - def initialize(responses) - @responses = responses - @completions = 0 - @dialect = nil - end - - def normalize_model_params(model_params) - # max_tokens, temperature, stop_sequences are already supported - model_params - end - - attr_reader :responses, :completions, :dialect, :model_params - - def prompt_messages - dialect.prompt.messages - end - - def perform_completion!( - dialect, - _user, - model_params, - feature_name: nil, - feature_context: nil, - partial_tool_calls: false, - output_thinking: false, - cancel_manager: nil - ) - @dialect = dialect - @model_params = model_params - response = responses[completions] - if response.nil? - raise CANNED_RESPONSE_ERROR, - "The number of completions you requested exceed the number of canned responses" - end - - raise response if response.is_a?(StandardError) - - @completions += 1 - if block_given? - cancelled = false - cancel_fn = lambda { cancelled = true } - - # We buffer and return tool invocations in one go. - as_array = response.is_a?(Array) ? response : [response] - as_array.each do |response| - if is_tool?(response) - yield(response, cancel_fn) - elsif is_thinking?(response) - yield(response, cancel_fn) - elsif model_params[:response_format].present? - structured_output = as_structured_output(response) - yield(structured_output, cancel_fn) - else - response.each_char do |char| - break if cancelled - yield(char, cancel_fn) - end - end - end - end - - response = response.first if response.is_a?(Array) && response.length == 1 - response = as_structured_output(response) if model_params[:response_format].present? - - response - end - - def tokenizer - DiscourseAi::Tokenizer::OpenAiTokenizer - end - - private - - def is_thinking?(response) - response.is_a?(DiscourseAi::Completions::Thinking) - end - - def is_tool?(response) - response.is_a?(DiscourseAi::Completions::ToolCall) - end - - def as_structured_output(response) - schema_properties = model_params[:response_format].dig(:json_schema, :schema, :properties) - return response if schema_properties.blank? - - output = DiscourseAi::Completions::StructuredOutput.new(schema_properties) - output << { schema_properties.keys.first => response }.to_json - - output - end - end - end - end -end diff --git a/lib/completions/endpoints/cohere.rb b/lib/completions/endpoints/cohere.rb deleted file mode 100644 index 258062a1..00000000 --- a/lib/completions/endpoints/cohere.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class Cohere < Base - def self.can_contact?(model_provider) - model_provider == "cohere" - end - - def normalize_model_params(model_params) - model_params = model_params.dup - model_params[:p] = model_params.delete(:top_p) if model_params[:top_p] - model_params - end - - def default_options(dialect) - { model: "command-r-plus" } - end - - def provider_id - AiApiAuditLog::Provider::Cohere - end - - private - - def model_uri - URI(llm_model.url) - end - - def prepare_payload(prompt, model_params, dialect) - payload = default_options(dialect).merge(model_params).merge(prompt) - if prompt[:tools].present? - payload[:tools] = prompt[:tools] - payload[:force_single_step] = false - end - payload[:tool_results] = prompt[:tool_results] if prompt[:tool_results].present? - payload[:stream] = true if @streaming_mode - - payload - end - - def prepare_request(payload) - headers = { - "Content-Type" => "application/json", - "Authorization" => "Bearer #{llm_model.api_key}", - } - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def decode(response_raw) - rval = [] - - parsed = JSON.parse(response_raw, symbolize_names: true) - - text = parsed[:text] - rval << parsed[:text] if !text.to_s.empty? # also allow " " - - # TODO tool calls - - update_usage(parsed) - - rval - end - - def decode_chunk(chunk) - @tool_idx ||= -1 - @json_decoder ||= JsonStreamDecoder.new(line_regex: /^\s*({.*})$/) - (@json_decoder << chunk) - .map do |parsed| - update_usage(parsed) - rval = [] - - rval << parsed[:text] if !parsed[:text].to_s.empty? - - if tool_calls = parsed[:tool_calls] - tool_calls&.each do |tool_call| - @tool_idx += 1 - tool_name = tool_call[:name] - tool_params = tool_call[:parameters] - tool_id = "tool_#{@tool_idx}" - rval << ToolCall.new(id: tool_id, name: tool_name, parameters: tool_params) - end - end - - rval - end - .flatten - .compact - end - - def extract_completion_from(response_raw) - parsed = JSON.parse(response_raw, symbolize_names: true) - - if @streaming_mode - if parsed[:event_type] == "text-generation" - parsed[:text] - elsif parsed[:event_type] == "tool-calls-generation" - # could just be random thinking... - if parsed.dig(:tool_calls).present? - @has_tool = true - parsed.dig(:tool_calls).to_json - else - "" - end - else - if parsed[:event_type] == "stream-end" - @input_tokens = parsed.dig(:response, :meta, :billed_units, :input_tokens) - @output_tokens = parsed.dig(:response, :meta, :billed_units, :output_tokens) - end - nil - end - else - @input_tokens = parsed.dig(:meta, :billed_units, :input_tokens) - @output_tokens = parsed.dig(:meta, :billed_units, :output_tokens) - parsed[:text].to_s - end - end - - def xml_tools_enabled? - false - end - - def final_log_update(log) - log.request_tokens = @input_tokens if @input_tokens - log.response_tokens = @output_tokens if @output_tokens - end - - def extract_prompt_for_tokenizer(prompt) - text = +"" - if prompt[:chat_history] - text << prompt[:chat_history] - .map { |message| message[:content] || message["content"] || "" } - .join("\n") - end - - text << prompt[:message] if prompt[:message] - text << prompt[:preamble] if prompt[:preamble] - - text - end - - private - - def update_usage(parsed) - input_tokens = parsed.dig(:meta, :billed_units, :input_tokens) - input_tokens ||= parsed.dig(:response, :meta, :billed_units, :input_tokens) - @input_tokens = input_tokens if input_tokens.present? - - output_tokens = parsed.dig(:meta, :billed_units, :output_tokens) - output_tokens ||= parsed.dig(:response, :meta, :billed_units, :output_tokens) - @output_tokens = output_tokens if output_tokens.present? - end - end - end - end -end diff --git a/lib/completions/endpoints/fake.rb b/lib/completions/endpoints/fake.rb deleted file mode 100644 index 71d0ee4b..00000000 --- a/lib/completions/endpoints/fake.rb +++ /dev/null @@ -1,175 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class Fake < Base - STOCK_CONTENT = <<~TEXT - # Discourse Markdown Styles Showcase - - Welcome to the **Discourse Markdown Styles Showcase**! This _post_ is designed to demonstrate a wide range of Markdown capabilities available in Discourse. - - ## Lists and Emphasis - - - **Bold Text**: To emphasize a point, you can use bold text. - - _Italic Text_: To subtly highlight text, italics are perfect. - - ~~Strikethrough~~: Sometimes, marking text as obsolete requires a strikethrough. - - > **Note**: Combining these _styles_ can **_really_** make your text stand out! - - 1. First item - 2. Second item - * Nested bullet - * Another nested bullet - 3. Third item - - ## Links and Images - - You can easily add [links](https://meta.discourse.org) to your posts. For adding images, use this syntax: - - ![Discourse Logo](https://meta.discourse.org/images/discourse-logo.svg) - - ## Code and Quotes - - Inline `code` is used for mentioning small code snippets like `let x = 10;`. For larger blocks of code, fenced code blocks are used: - - ```javascript - function greet() { - console.log("Hello, Discourse Community!"); - } - greet(); - ``` - - > Blockquotes can be very effective for highlighting user comments or important sections from cited sources. They stand out visually and offer great readability. - - ## Tables and Horizontal Rules - - Creating tables in Markdown is straightforward: - - | Header 1 | Header 2 | Header 3 | - | ---------|:--------:| --------:| - | Row 1, Col 1 | Centered | Right-aligned | - | Row 2, Col 1 | **Bold** | _Italic_ | - | Row 3, Col 1 | `Inline Code` | [Link](https://meta.discourse.org) | - - To separate content sections: - - --- - - ## Final Thoughts - - Congratulations, you've now seen a small sample of what Discourse's Markdown can do! For more intricate formatting, consider exploring the advanced styling options. Remember that the key to great formatting is not just the available tools, but also the **clarity** and **readability** it brings to your readers. - TEXT - - def self.can_contact?(model_provider) - model_provider == "fake" - end - - def self.with_fake_content(content) - @fake_content = content - yield - ensure - @fake_content = nil - end - - def self.fake_content=(content) - @fake_content = content - end - - def self.fake_content - @fake_content || STOCK_CONTENT - end - - def self.delays - @delays ||= Array.new(10) { Rails.env.test? ? 0 : rand(0..5) } - end - - def self.delays=(delays) - @delays = delays - end - - def self.chunk_count - @chunk_count ||= 10 - end - - def self.chunk_count=(chunk_count) - @chunk_count = chunk_count - end - - def self.last_call - @last_call - end - - def self.last_call=(params) - @last_call = params - end - - def self.previous_calls - @previous_calls ||= [] - end - - def self.reset! - @last_call = nil - @fake_content = nil - @delays = nil - @chunk_count = nil - end - - def perform_completion!( - dialect, - user, - model_params = {}, - feature_name: nil, - feature_context: nil, - partial_tool_calls: false, - output_thinking: false, - cancel_manager: nil - ) - last_call = { dialect: dialect, user: user, model_params: model_params } - self.class.last_call = last_call - self.class.previous_calls << last_call - # guard memory in test - self.class.previous_calls.shift if self.class.previous_calls.length > 10 - - content = self.class.fake_content - - content = content.shift if content.is_a?(Array) - - if block_given? - if content.is_a?(DiscourseAi::Completions::ToolCall) - yield(content, -> {}) - else - split_indices = (1...content.length).to_a.sample(self.class.chunk_count - 1).sort - indexes = [0, *split_indices, content.length] - - original_content = content - content = +"" - - cancel = false - cancel_proc = -> { cancel = true } - - i = 0 - indexes - .each_cons(2) - .map { |start, finish| original_content[start...finish] } - .each do |chunk| - break if cancel - if self.class.delays.present? && - (delay = self.class.delays[i % self.class.delays.length]) - sleep(delay) - i += 1 - end - break if cancel - - content << chunk - yield(chunk, cancel_proc) - end - end - end - - content - end - end - end - end -end diff --git a/lib/completions/endpoints/gemini.rb b/lib/completions/endpoints/gemini.rb deleted file mode 100644 index f5b1cb19..00000000 --- a/lib/completions/endpoints/gemini.rb +++ /dev/null @@ -1,257 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class Gemini < Base - def self.can_contact?(model_provider) - model_provider == "google" - end - - def default_options - # the default setting is a problem, it blocks too much - categories = %w[HARASSMENT SEXUALLY_EXPLICIT HATE_SPEECH DANGEROUS_CONTENT] - - safety_settings = - categories.map do |category| - { category: "HARM_CATEGORY_#{category}", threshold: "BLOCK_NONE" } - end - - { generationConfig: {}, safetySettings: safety_settings } - end - - def normalize_model_params(model_params) - model_params = model_params.dup - - if model_params[:stop_sequences] - model_params[:stopSequences] = model_params.delete(:stop_sequences) - end - - if model_params[:max_tokens] - model_params[:maxOutputTokens] = model_params.delete(:max_tokens) - end - - model_params[:topP] = model_params.delete(:top_p) if model_params[:top_p] - - model_params.delete(:temperature) if llm_model.lookup_custom_param("disable_temperature") - model_params.delete(:topP) if llm_model.lookup_custom_param("disable_top_p") - - model_params - end - - def provider_id - AiApiAuditLog::Provider::Gemini - end - - private - - def model_uri - url = llm_model.url - key = llm_model.api_key - - if @streaming_mode - url = "#{url}:streamGenerateContent?key=#{key}&alt=sse" - else - url = "#{url}:generateContent?key=#{key}" - end - - URI(url) - end - - def prepare_payload(prompt, model_params, dialect) - @native_tool_support = dialect.native_tool_support? - - tools = dialect.tools if @native_tool_support - - payload = default_options.merge(contents: prompt[:messages]) - - payload[:systemInstruction] = { - role: "system", - parts: [{ text: prompt[:system_instruction].to_s }], - } if prompt[:system_instruction].present? - if tools.present? - payload[:tools] = tools - - function_calling_config = { mode: "AUTO" } - if dialect.tool_choice.present? - if dialect.tool_choice == :none - function_calling_config = { mode: "NONE" } - else - function_calling_config = { - mode: "ANY", - allowed_function_names: [dialect.tool_choice], - } - end - end - - payload[:tool_config] = { function_calling_config: function_calling_config } - end - if model_params.present? - payload[:generationConfig].merge!(model_params.except(:response_format)) - - # https://ai.google.dev/api/generate-content#generationconfig - gemini_schema = model_params.dig(:response_format, :json_schema, :schema) - - if gemini_schema.present? - payload[:generationConfig][:responseSchema] = gemini_schema.except( - :additionalProperties, - ) - payload[:generationConfig][:responseMimeType] = "application/json" - end - end - - if llm_model.lookup_custom_param("enable_thinking") - thinking_tokens = llm_model.lookup_custom_param("thinking_tokens").to_i - thinking_tokens = thinking_tokens.clamp(0, 24_576) - payload[:generationConfig][:thinkingConfig] = { thinkingBudget: thinking_tokens } - end - - payload - end - - def prepare_request(payload) - headers = { "Content-Type" => "application/json" } - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def extract_completion_from(response_raw) - parsed = - if @streaming_mode - response_raw - else - JSON.parse(response_raw, symbolize_names: true) - end - response_h = parsed.dig(:candidates, 0, :content, :parts, 0) - - if response_h - @has_function_call ||= response_h.dig(:functionCall).present? - @has_function_call ? response_h.dig(:functionCall) : response_h.dig(:text) - end - end - - class GeminiStreamingDecoder - def initialize - @buffer = +"" - end - - def decode(str) - @buffer << str - - lines = @buffer.split(/\r?\n\r?\n/) - - keep_last = false - - decoded = - lines - .map do |line| - if line.start_with?("data: {") - begin - JSON.parse(line[6..-1], symbolize_names: true) - rescue JSON::ParserError - keep_last = line - nil - end - else - keep_last = line - nil - end - end - .compact - - if keep_last - @buffer = +(keep_last) - else - @buffer = +"" - end - - decoded - end - end - - def decode(chunk) - json = JSON.parse(chunk, symbolize_names: true) - - idx = -1 - json - .dig(:candidates, 0, :content, :parts) - .map do |part| - if part[:functionCall] - idx += 1 - ToolCall.new( - id: "tool_#{idx}", - name: part[:functionCall][:name], - parameters: part[:functionCall][:args], - ) - else - part = part[:text] - if part != "" - part - else - nil - end - end - end - end - - def decode_chunk(chunk) - @tool_index ||= -1 - streaming_decoder - .decode(chunk) - .map do |parsed| - update_usage(parsed) - parts = parsed.dig(:candidates, 0, :content, :parts) - parts&.map do |part| - if part[:text] - part = part[:text] - if part != "" - part - else - nil - end - elsif part[:functionCall] - @tool_index += 1 - ToolCall.new( - id: "tool_#{@tool_index}", - name: part[:functionCall][:name], - parameters: part[:functionCall][:args], - ) - end - end - end - .flatten - .compact - end - - def update_usage(parsed) - usage = parsed.dig(:usageMetadata) - if usage - if prompt_token_count = usage[:promptTokenCount] - @prompt_token_count = prompt_token_count - end - if candidate_token_count = usage[:candidatesTokenCount] - @candidate_token_count = candidate_token_count - end - end - end - - def final_log_update(log) - log.request_tokens = @prompt_token_count if @prompt_token_count - log.response_tokens = @candidate_token_count if @candidate_token_count - end - - def streaming_decoder - @decoder ||= GeminiStreamingDecoder.new - end - - def extract_prompt_for_tokenizer(prompt) - prompt.to_s - end - - def xml_tools_enabled? - !@native_tool_support - end - end - end - end -end diff --git a/lib/completions/endpoints/hugging_face.rb b/lib/completions/endpoints/hugging_face.rb deleted file mode 100644 index b0b14722..00000000 --- a/lib/completions/endpoints/hugging_face.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class HuggingFace < Base - def self.can_contact?(model_provider) - model_provider == "hugging_face" - end - - def normalize_model_params(model_params) - model_params = model_params.dup - - # max_tokens, temperature are already supported - if model_params[:stop_sequences] - model_params[:stop] = model_params.delete(:stop_sequences) - end - - model_params - end - - def default_options - { model: llm_model.name, temperature: 0.7 } - end - - def provider_id - AiApiAuditLog::Provider::HuggingFaceTextGeneration - end - - private - - def model_uri - URI(llm_model.url) - end - - def prepare_payload(prompt, model_params, _dialect) - default_options - .merge(model_params) - .merge(messages: prompt) - .tap do |payload| - if !payload[:max_tokens] - token_limit = llm_model.max_prompt_tokens - - payload[:max_tokens] = token_limit - prompt_size(prompt) - end - - payload[:stream] = true if @streaming_mode - end - end - - def prepare_request(payload) - api_key = llm_model.api_key - - headers = - { "Content-Type" => "application/json" }.tap do |h| - h["Authorization"] = "Bearer #{api_key}" if api_key.present? - end - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def xml_tools_enabled? - true - end - - def decode(response_raw) - parsed = JSON.parse(response_raw, symbolize_names: true) - text = parsed.dig(:choices, 0, :message, :content) - if text.to_s.empty? - [""] - else - [text] - end - end - - def decode_chunk(chunk) - @json_decoder ||= JsonStreamDecoder.new - (@json_decoder << chunk) - .map do |parsed| - text = parsed.dig(:choices, 0, :delta, :content) - if text.to_s.empty? - nil - else - text - end - end - .compact - end - end - end - end -end diff --git a/lib/completions/endpoints/mistral.rb b/lib/completions/endpoints/mistral.rb deleted file mode 100644 index 5414b3df..00000000 --- a/lib/completions/endpoints/mistral.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class Mistral < OpenAi - def self.can_contact?(model_provider) - model_provider == "mistral" - end - - def provider_id - AiApiAuditLog::Provider::Mistral - end - end - end - end -end diff --git a/lib/completions/endpoints/ollama.rb b/lib/completions/endpoints/ollama.rb deleted file mode 100644 index dd4ca2c7..00000000 --- a/lib/completions/endpoints/ollama.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class Ollama < Base - def self.can_contact?(model_provider) - model_provider == "ollama" - end - - def normalize_model_params(model_params) - model_params = model_params.dup - - # max_tokens, temperature are already supported - if model_params[:stop_sequences] - model_params[:stop] = model_params.delete(:stop_sequences) - end - - model_params - end - - def default_options - { max_tokens: 2000, model: llm_model.name } - end - - def provider_id - AiApiAuditLog::Provider::Ollama - end - - def use_ssl? - false - end - - private - - def model_uri - URI(llm_model.url) - end - - def xml_tools_enabled? - !@native_tool_support - end - - def prepare_payload(prompt, model_params, dialect) - @native_tool_support = dialect.native_tool_support? - - # https://github.com/ollama/ollama/blob/main/docs/api.md#parameters-1 - # Due to ollama enforce a 'stream: false' for tool calls, instead of complicating the code, - # we will just disable streaming for all ollama calls if native tool support is enabled - - default_options - .merge(model_params) - .merge(messages: prompt) - .tap { |payload| payload[:stream] = false if @native_tool_support || !@streaming_mode } - .tap do |payload| - payload[:tools] = dialect.tools if @native_tool_support && dialect.tools.present? - end - end - - def prepare_request(payload) - headers = { "Content-Type" => "application/json" } - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def decode_chunk(chunk) - # Native tool calls are not working right in streaming mode, use XML - @json_decoder ||= JsonStreamDecoder.new(line_regex: /^\s*({.*})$/) - (@json_decoder << chunk).map { |parsed| parsed.dig(:message, :content) }.compact - end - - def decode(response_raw) - rval = [] - parsed = JSON.parse(response_raw, symbolize_names: true) - content = parsed.dig(:message, :content) - rval << content if !content.to_s.empty? - - idx = -1 - parsed - .dig(:message, :tool_calls) - &.each do |tool_call| - idx += 1 - id = "tool_#{idx}" - name = tool_call.dig(:function, :name) - args = tool_call.dig(:function, :arguments) - rval << ToolCall.new(id: id, name: name, parameters: args) - end - - rval - end - end - end - end -end diff --git a/lib/completions/endpoints/open_ai.rb b/lib/completions/endpoints/open_ai.rb deleted file mode 100644 index 36e49a4a..00000000 --- a/lib/completions/endpoints/open_ai.rb +++ /dev/null @@ -1,193 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class OpenAi < Base - def self.can_contact?(model_provider) - %w[open_ai azure].include?(model_provider) - end - - def normalize_model_params(model_params) - model_params = model_params.dup - - # max_tokens is deprecated however we still need to support it - # on older OpenAI models and older Azure models, so we will only normalize - # if our model name starts with o (to denote all the reasoning models) - if llm_model.name.starts_with?("o") - max_tokens = model_params.delete(:max_tokens) - model_params[:max_completion_tokens] = max_tokens if max_tokens - end - - # temperature is already supported - if model_params[:stop_sequences] - model_params[:stop] = model_params.delete(:stop_sequences) - end - - model_params.delete(:top_p) if llm_model.lookup_custom_param("disable_top_p") - model_params.delete(:temperature) if llm_model.lookup_custom_param("disable_temperature") - - model_params - end - - def default_options - { model: llm_model.name } - end - - def provider_id - AiApiAuditLog::Provider::OpenAI - end - - def perform_completion!( - dialect, - user, - model_params = {}, - feature_name: nil, - feature_context: nil, - partial_tool_calls: false, - output_thinking: false, - cancel_manager: nil, - &blk - ) - @disable_native_tools = dialect.disable_native_tools? - super - end - - private - - def disable_streaming? - @disable_streaming ||= llm_model.lookup_custom_param("disable_streaming") - end - - def reasoning_effort - return @reasoning_effort if defined?(@reasoning_effort) - @reasoning_effort = llm_model.lookup_custom_param("reasoning_effort") - @reasoning_effort = nil if !%w[low medium high].include?(@reasoning_effort) - @reasoning_effort - end - - def model_uri - if llm_model.url.to_s.starts_with?("srv://") - service = DiscourseAi::Utils::DnsSrv.lookup(llm_model.url.sub("srv://", "")) - api_endpoint = "https://#{service.target}:#{service.port}/v1/chat/completions" - else - api_endpoint = llm_model.url - end - - @uri ||= URI(api_endpoint) - end - - def prepare_payload(prompt, model_params, dialect) - payload = default_options.merge(model_params).merge(messages: prompt) - - payload[:reasoning_effort] = reasoning_effort if reasoning_effort - - if @streaming_mode - payload[:stream] = true - - # Usage is not available in Azure yet. - # We'll fallback to guess this using the tokenizer. - payload[:stream_options] = { include_usage: true } if llm_model.provider == "open_ai" - end - - if !xml_tools_enabled? - if dialect.tools.present? - payload[:tools] = dialect.tools - if dialect.tool_choice.present? - if dialect.tool_choice == :none - payload[:tool_choice] = "none" - else - if responses_api? - payload[:tool_choice] = { type: "function", name: dialect.tool_choice } - else - payload[:tool_choice] = { - type: "function", - function: { - name: dialect.tool_choice, - }, - } - end - end - end - end - end - - convert_payload_to_responses_api!(payload) if responses_api? - - payload - end - - def responses_api? - return @responses_api if defined?(@responses_api) - @responses_api = llm_model.lookup_custom_param("enable_responses_api") - end - - def convert_payload_to_responses_api!(payload) - payload[:input] = payload.delete(:messages) - completion_tokens = payload.delete(:max_completion_tokens) || payload.delete(:max_tokens) - payload[:max_output_tokens] = completion_tokens if completion_tokens - # not supported in responses api - payload.delete(:stream_options) - end - - def prepare_request(payload) - headers = { "Content-Type" => "application/json" } - api_key = llm_model.api_key - - if llm_model.provider == "azure" - headers["api-key"] = api_key - else - headers["Authorization"] = "Bearer #{api_key}" - org_id = llm_model.lookup_custom_param("organization") - headers["OpenAI-Organization"] = org_id if org_id.present? - end - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def final_log_update(log) - log.request_tokens = processor.prompt_tokens if processor.prompt_tokens - log.response_tokens = processor.completion_tokens if processor.completion_tokens - log.cached_tokens = processor.cached_tokens if processor.cached_tokens - end - - def decode(response_raw) - processor.process_message(JSON.parse(response_raw, symbolize_names: true)) - end - - def decode_chunk(chunk) - @decoder ||= JsonStreamDecoder.new - elements = - (@decoder << chunk) - .map { |parsed_json| processor.process_streamed_message(parsed_json) } - .flatten - .compact - - # Remove duplicate partial tool calls - # sometimes we stream weird chunks - seen_tools = Set.new - elements.select { |item| !item.is_a?(ToolCall) || seen_tools.add?(item) } - end - - def decode_chunk_finish - processor.finish - end - - def xml_tools_enabled? - !!@disable_native_tools - end - - private - - def processor - @processor ||= - if responses_api? - OpenAiResponsesMessageProcessor.new(partial_tool_calls: partial_tool_calls) - else - OpenAiMessageProcessor.new(partial_tool_calls: partial_tool_calls) - end - end - end - end - end -end diff --git a/lib/completions/endpoints/open_router.rb b/lib/completions/endpoints/open_router.rb deleted file mode 100644 index 08122799..00000000 --- a/lib/completions/endpoints/open_router.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class OpenRouter < OpenAi - def self.can_contact?(model_provider) - %w[open_router].include?(model_provider) - end - - def normalize_model_params(model_params) - model_params = model_params.dup - - # max_tokens, temperature are already supported - if model_params[:stop_sequences] - model_params[:stop] = model_params.delete(:stop_sequences) - end - - model_params.delete(:top_p) if llm_model.lookup_custom_param("disable_top_p") - model_params.delete(:temperature) if llm_model.lookup_custom_param("disable_temperature") - - model_params - end - - def prepare_request(payload) - headers = { "Content-Type" => "application/json" } - api_key = llm_model.api_key - - headers["Authorization"] = "Bearer #{api_key}" - headers["X-Title"] = "Discourse AI" - headers["HTTP-Referer"] = "https://www.discourse.org/ai" - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def prepare_payload(prompt, model_params, dialect) - payload = super - - if quantizations = llm_model.provider_params["provider_quantizations"].presence - options = quantizations.split(",").map(&:strip) - - payload[:provider] = { quantizations: options } - end - - if order = llm_model.provider_params["provider_order"].presence - options = order.split(",").map(&:strip) - payload[:provider] ||= {} - payload[:provider][:order] = options - end - - payload - end - end - end - end -end diff --git a/lib/completions/endpoints/samba_nova.rb b/lib/completions/endpoints/samba_nova.rb deleted file mode 100644 index 9e6b3817..00000000 --- a/lib/completions/endpoints/samba_nova.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class SambaNova < Base - def self.can_contact?(model_provider) - model_provider == "samba_nova" - end - - def normalize_model_params(model_params) - model_params = model_params.dup - - # max_tokens, temperature are already supported - if model_params[:stop_sequences] - model_params[:stop] = model_params.delete(:stop_sequences) - end - - model_params - end - - def default_options - { model: llm_model.name } - end - - def provider_id - AiApiAuditLog::Provider::SambaNova - end - - private - - def model_uri - URI(llm_model.url) - end - - def prepare_payload(prompt, model_params, dialect) - payload = - default_options.merge(model_params.except(:response_format)).merge(messages: prompt) - - if model_params[:response_format].present? - payload[:response_format] = { type: "json_object" } - end - - payload[:stream] = true if @streaming_mode - - payload - end - - def prepare_request(payload) - headers = { "Content-Type" => "application/json" } - api_key = llm_model.api_key - - headers["Authorization"] = "Bearer #{api_key}" - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def final_log_update(log) - log.request_tokens = @prompt_tokens if @prompt_tokens - log.response_tokens = @completion_tokens if @completion_tokens - end - - def xml_tools_enabled? - true - end - - def decode(response_raw) - json = JSON.parse(response_raw, symbolize_names: true) - [json.dig(:choices, 0, :message, :content)] - end - - def decode_chunk(chunk) - @json_decoder ||= JsonStreamDecoder.new - (@json_decoder << chunk) - .map do |json| - text = json.dig(:choices, 0, :delta, :content) - - @prompt_tokens ||= json.dig(:usage, :prompt_tokens) - @completion_tokens ||= json.dig(:usage, :completion_tokens) - - if !text.to_s.empty? - text - else - nil - end - end - .flatten - .compact - end - end - end - end -end diff --git a/lib/completions/endpoints/vllm.rb b/lib/completions/endpoints/vllm.rb deleted file mode 100644 index 6b371a09..00000000 --- a/lib/completions/endpoints/vllm.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - module Endpoints - class Vllm < Base - def self.can_contact?(model_provider) - model_provider == "vllm" - end - - def normalize_model_params(model_params) - model_params = model_params.dup - - # max_tokens, temperature are already supported - if model_params[:stop_sequences] - model_params[:stop] = model_params.delete(:stop_sequences) - end - - model_params - end - - def default_options - { max_tokens: 2000, model: llm_model.name } - end - - def provider_id - AiApiAuditLog::Provider::Vllm - end - - private - - def model_uri - if llm_model.url.to_s.starts_with?("srv://") - service = DiscourseAi::Utils::DnsSrv.lookup(llm_model.url.sub("srv://", "")) - api_endpoint = "https://#{service.target}:#{service.port}/v1/chat/completions" - else - api_endpoint = llm_model.url - end - - @uri ||= URI(api_endpoint) - end - - def prepare_payload(prompt, model_params, dialect) - payload = default_options.merge(model_params).merge(messages: prompt) - if @streaming_mode - payload[:stream] = true if @streaming_mode - payload[:stream_options] = { include_usage: true } - end - - payload - end - - def prepare_request(payload) - headers = { "Referer" => Discourse.base_url, "Content-Type" => "application/json" } - - api_key = llm_model&.api_key || SiteSetting.ai_vllm_api_key - headers["X-API-KEY"] = api_key if api_key.present? - - Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } - end - - def xml_tools_enabled? - true - end - - def final_log_update(log) - log.request_tokens = @prompt_tokens if @prompt_tokens - log.response_tokens = @completion_tokens if @completion_tokens - end - - def decode(response_raw) - json = JSON.parse(response_raw, symbolize_names: true) - @prompt_tokens = json.dig(:usage, :prompt_tokens) - @completion_tokens = json.dig(:usage, :completion_tokens) - [json.dig(:choices, 0, :message, :content)] - end - - def decode_chunk(chunk) - @json_decoder ||= JsonStreamDecoder.new - (@json_decoder << chunk) - .map do |parsed| - # vLLM keeps sending usage over and over again - prompt_tokens = parsed.dig(:usage, :prompt_tokens) - completion_tokens = parsed.dig(:usage, :completion_tokens) - - @prompt_tokens = prompt_tokens if prompt_tokens - - @completion_tokens = completion_tokens if completion_tokens - - text = parsed.dig(:choices, 0, :delta, :content) - if text.to_s.empty? - nil - else - text - end - end - .compact - end - end - end - end -end diff --git a/lib/completions/json_stream_decoder.rb b/lib/completions/json_stream_decoder.rb deleted file mode 100644 index e575a3b7..00000000 --- a/lib/completions/json_stream_decoder.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - # will work for anthropic and open ai compatible - class JsonStreamDecoder - attr_reader :buffer - - LINE_REGEX = /data: ({.*})\s*$/ - - def initialize(symbolize_keys: true, line_regex: LINE_REGEX) - @symbolize_keys = symbolize_keys - @buffer = +"" - @line_regex = line_regex - end - - def <<(raw) - @buffer << raw.to_s - rval = [] - - split = @buffer.scan(/.*\n?/) - split.pop if split.last.blank? - - @buffer = +(split.pop.to_s) - - split.each do |line| - matches = line.match(@line_regex) - next if !matches - rval << JSON.parse(matches[1], symbolize_names: @symbolize_keys) - end - - if @buffer.present? - matches = @buffer.match(@line_regex) - if matches - begin - rval << JSON.parse(matches[1], symbolize_names: @symbolize_keys) - @buffer = +"" - rescue JSON::ParserError - # maybe it is a partial line - end - end - end - - rval - end - end - end -end diff --git a/lib/completions/json_streaming_parser.rb b/lib/completions/json_streaming_parser.rb deleted file mode 100644 index f3288ab6..00000000 --- a/lib/completions/json_streaming_parser.rb +++ /dev/null @@ -1,668 +0,0 @@ -# frozen_string_literal: true - -# This code is copied from the MIT licensed json-stream -# see: https://github.com/dgraham/json-stream -# -# It was copied to avoid the dependency and allow us to make some small changes -# particularly we need better access to internal state when parsing - -module DiscourseAi - module Completions - # Raised on any invalid JSON text. - ParserError = Class.new(RuntimeError) - - # A streaming JSON parser that generates SAX-like events for state changes. - # Use the json gem for small documents. Use this for huge documents that - # won't fit in memory. - # - # Examples - # - # parser = JSON::Stream::Parser.new - # parser.key { |key| puts key } - # parser.value { |value| puts value } - # parser << '{"answer":' - # parser << ' 42}' - class JsonStreamingParser - # our changes: - attr_reader :state, :buf, :pos - - # A character buffer that expects a UTF-8 encoded stream of bytes. - # This handles truncated multi-byte characters properly so we can just - # feed it binary data and receive a properly formatted UTF-8 String as - # output. - # - # More UTF-8 parsing details are available at: - # - # http://en.wikipedia.org/wiki/UTF-8 - # http://tools.ietf.org/html/rfc3629#section-3 - class Buffer - def initialize - @state = :start - @buffer = [] - @need = 0 - end - - # Fill the buffer with a String of binary UTF-8 encoded bytes. Returns - # as much of the data in a UTF-8 String as we have. Truncated multi-byte - # characters are saved in the buffer until the next call to this method - # where we expect to receive the rest of the multi-byte character. - # - # data - The partial binary encoded String data. - # - # Raises JSON::Stream::ParserError if the UTF-8 byte sequence is malformed. - # - # Returns a UTF-8 encoded String. - def <<(data) - data = data.dup if data.frozen? - # Avoid state machine for complete UTF-8. - if @buffer.empty? - data.force_encoding(Encoding::UTF_8) - return data if data.valid_encoding? - end - - bytes = [] - data.each_byte do |byte| - case @state - when :start - if byte < 128 - bytes << byte - elsif byte >= 192 - @state = :multi_byte - @buffer << byte - @need = - case - when byte >= 240 - 4 - when byte >= 224 - 3 - when byte >= 192 - 2 - end - else - error("Expected start of multi-byte or single byte char") - end - when :multi_byte - if byte > 127 && byte < 192 - @buffer << byte - if @buffer.size == @need - bytes += @buffer.slice!(0, @buffer.size) - @state = :start - end - else - error("Expected continuation byte") - end - end - end - - # Build UTF-8 encoded string from completed codepoints. - bytes - .pack("C*") - .force_encoding(Encoding::UTF_8) - .tap { |text| error("Invalid UTF-8 byte sequence") unless text.valid_encoding? } - end - - # Determine if the buffer contains partial UTF-8 continuation bytes that - # are waiting on subsequent completion bytes before a full codepoint is - # formed. - # - # Examples - # - # bytes = "é".bytes - # - # buffer << bytes[0] - # buffer.empty? - # # => false - # - # buffer << bytes[1] - # buffer.empty? - # # => true - # - # Returns true if the buffer is empty. - def empty? - @buffer.empty? - end - - private - - def error(message) - raise ParserError, message - end - end - - BUF_SIZE = 4096 - CONTROL = /[\x00-\x1F]/ - WS = /[ \n\t\r]/ - HEX = /[0-9a-fA-F]/ - DIGIT = /[0-9]/ - DIGIT_1_9 = /[1-9]/ - DIGIT_END = /\d$/ - TRUE_RE = /[rue]/ - FALSE_RE = /[alse]/ - NULL_RE = /[ul]/ - TRUE_KEYWORD = "true" - FALSE_KEYWORD = "false" - NULL_KEYWORD = "null" - LEFT_BRACE = "{" - RIGHT_BRACE = "}" - LEFT_BRACKET = "[" - RIGHT_BRACKET = "]" - BACKSLASH = '\\' - SLASH = "/" - QUOTE = '"' - COMMA = "," - COLON = ":" - ZERO = "0" - MINUS = "-" - PLUS = "+" - POINT = "." - EXPONENT = /[eE]/ - B, F, N, R, T, U = %w[b f n r t u] - - # Create a new parser with an optional initialization block where - # we can register event callbacks. - # - # Examples - # - # parser = JSON::Stream::Parser.new do - # start_document { puts "start document" } - # end_document { puts "end document" } - # start_object { puts "start object" } - # end_object { puts "end object" } - # start_array { puts "start array" } - # end_array { puts "end array" } - # key { |k| puts "key: #{k}" } - # value { |v| puts "value: #{v}" } - # end - def initialize(&block) - @state = :start_document - @utf8 = Buffer.new - @listeners = { - start_document: [], - end_document: [], - start_object: [], - end_object: [], - start_array: [], - end_array: [], - key: [], - value: [], - } - - # Track parse stack. - @stack = [] - @unicode = +"" - @buf = +"" - @pos = -1 - - # Register any observers in the block. - instance_eval(&block) if block_given? - end - - def start_document(&block) - @listeners[:start_document] << block - end - - def end_document(&block) - @listeners[:end_document] << block - end - - def start_object(&block) - @listeners[:start_object] << block - end - - def end_object(&block) - @listeners[:end_object] << block - end - - def start_array(&block) - @listeners[:start_array] << block - end - - def end_array(&block) - @listeners[:end_array] << block - end - - def key(&block) - @listeners[:key] << block - end - - def value(&block) - @listeners[:value] << block - end - - # Pass data into the parser to advance the state machine and - # generate callback events. This is well suited for an EventMachine - # receive_data loop. - # - # data - The String of partial JSON data to parse. - # - # Raises a JSON::Stream::ParserError if the JSON data is malformed. - # - # Returns nothing. - def <<(data) - (@utf8 << data).each_char do |ch| - @pos += 1 - case @state - when :start_document - start_value(ch) - when :start_object - case ch - when QUOTE - @state = :start_string - @stack.push(:key) - when RIGHT_BRACE - end_container(:object) - when WS - # ignore - else - error("Expected object key start") - end - when :start_string - case ch - when QUOTE - if @stack.pop == :string - end_value(@buf) - else # :key - @state = :end_key - notify(:key, @buf) - end - @buf = +"" - when BACKSLASH - @state = :start_escape - when CONTROL - error("Control characters must be escaped") - else - @buf << ch - end - when :start_escape - case ch - when QUOTE, BACKSLASH, SLASH - @buf << ch - @state = :start_string - when B - @buf << "\b" - @state = :start_string - when F - @buf << "\f" - @state = :start_string - when N - @buf << "\n" - @state = :start_string - when R - @buf << "\r" - @state = :start_string - when T - @buf << "\t" - @state = :start_string - when U - @state = :unicode_escape - else - error("Expected escaped character") - end - when :unicode_escape - case ch - when HEX - @unicode << ch - if @unicode.size == 4 - codepoint = @unicode.slice!(0, 4).hex - if codepoint >= 0xD800 && codepoint <= 0xDBFF - error("Expected low surrogate pair half") if @stack[-1].is_a?(Integer) - @state = :start_surrogate_pair - @stack.push(codepoint) - elsif codepoint >= 0xDC00 && codepoint <= 0xDFFF - high = @stack.pop - error("Expected high surrogate pair half") unless high.is_a?(Integer) - pair = ((high - 0xD800) * 0x400) + (codepoint - 0xDC00) + 0x10000 - @buf << pair - @state = :start_string - else - @buf << codepoint - @state = :start_string - end - end - else - error("Expected unicode escape hex digit") - end - when :start_surrogate_pair - case ch - when BACKSLASH - @state = :start_surrogate_pair_u - else - error("Expected low surrogate pair half") - end - when :start_surrogate_pair_u - case ch - when U - @state = :unicode_escape - else - error("Expected low surrogate pair half") - end - when :start_negative_number - case ch - when ZERO - @state = :start_zero - @buf << ch - when DIGIT_1_9 - @state = :start_int - @buf << ch - else - error("Expected 0-9 digit") - end - when :start_zero - case ch - when POINT - @state = :start_float - @buf << ch - when EXPONENT - @state = :start_exponent - @buf << ch - else - end_value(@buf.to_i) - @buf = +"" - @pos -= 1 - redo - end - when :start_float - case ch - when DIGIT - @state = :in_float - @buf << ch - else - error("Expected 0-9 digit") - end - when :in_float - case ch - when DIGIT - @buf << ch - when EXPONENT - @state = :start_exponent - @buf << ch - else - end_value(@buf.to_f) - @buf = +"" - @pos -= 1 - redo - end - when :start_exponent - case ch - when MINUS, PLUS, DIGIT - @state = :in_exponent - @buf << ch - else - error("Expected +, -, or 0-9 digit") - end - when :in_exponent - case ch - when DIGIT - @buf << ch - else - error("Expected 0-9 digit") unless @buf =~ DIGIT_END - end_value(@buf.to_f) - @buf = +"" - @pos -= 1 - redo - end - when :start_int - case ch - when DIGIT - @buf << ch - when POINT - @state = :start_float - @buf << ch - when EXPONENT - @state = :start_exponent - @buf << ch - else - end_value(@buf.to_i) - @buf = +"" - @pos -= 1 - redo - end - when :start_true - keyword(TRUE_KEYWORD, true, TRUE_RE, ch) - when :start_false - keyword(FALSE_KEYWORD, false, FALSE_RE, ch) - when :start_null - keyword(NULL_KEYWORD, nil, NULL_RE, ch) - when :end_key - case ch - when COLON - @state = :key_sep - when WS - # ignore - else - error("Expected colon key separator") - end - when :key_sep - start_value(ch) - when :start_array - case ch - when RIGHT_BRACKET - end_container(:array) - when WS - # ignore - else - start_value(ch) - end - when :end_value - case ch - when COMMA - @state = :value_sep - when RIGHT_BRACE - end_container(:object) - when RIGHT_BRACKET - end_container(:array) - when WS - # ignore - else - error("Expected comma or object or array close") - end - when :value_sep - if @stack[-1] == :object - case ch - when QUOTE - @state = :start_string - @stack.push(:key) - when WS - # ignore - else - error("Expected object key start") - end - else - start_value(ch) - end - when :end_document - error("Unexpected data") unless ch =~ WS - end - end - end - - # Drain any remaining buffered characters into the parser to complete - # the parsing of the document. - # - # This is only required when parsing a document containing a single - # numeric value, integer or float. The parser has no other way to - # detect when it should no longer expect additional characters with - # which to complete the parse, so it must be signaled by a call to - # this method. - # - # If you're parsing more typical object or array documents, there's no - # need to call `finish` because the parse will complete when the final - # closing `]` or `}` character is scanned. - # - # Raises a JSON::Stream::ParserError if the JSON data is malformed. - # - # Returns nothing. - def finish - # Partial multi-byte character waiting for completion bytes. - error("Unexpected end-of-file") unless @utf8.empty? - - # Partial array, object, or string. - error("Unexpected end-of-file") unless @stack.empty? - - case @state - when :end_document - # done, do nothing - when :in_float - end_value(@buf.to_f) - when :in_exponent - error("Unexpected end-of-file") unless @buf =~ DIGIT_END - end_value(@buf.to_f) - when :start_zero - end_value(@buf.to_i) - when :start_int - end_value(@buf.to_i) - else - error("Unexpected end-of-file") - end - end - - private - - # Invoke all registered observer procs for the event type. - # - # type - The Symbol listener name. - # args - The argument list to pass into the observer procs. - # - # Examples - # - # # broadcast events for {"answer": 42} - # notify(:start_object) - # notify(:key, "answer") - # notify(:value, 42) - # notify(:end_object) - # - # Returns nothing. - def notify(type, *args) - @listeners[type].each { |block| block.call(*args) } - end - - # Complete an object or array container value type. - # - # type - The Symbol, :object or :array, of the expected type. - # - # Raises a JSON::Stream::ParserError if the expected container type - # was not completed. - # - # Returns nothing. - def end_container(type) - @state = :end_value - if @stack.pop == type - case type - when :object - notify(:end_object) - when :array - notify(:end_array) - end - else - error("Expected end of #{type}") - end - notify_end_document if @stack.empty? - end - - # Broadcast an `end_document` event to observers after a complete JSON - # value document (object, array, number, string, true, false, null) has - # been parsed from the text. This is the final event sent to observers - # and signals the parse has finished. - # - # Returns nothing. - def notify_end_document - @state = :end_document - notify(:end_document) - end - - # Parse one of the three allowed keywords: true, false, null. - # - # word - The String keyword ('true', 'false', 'null'). - # value - The Ruby value (true, false, nil). - # re - The Regexp of allowed keyword characters. - # ch - The current String character being parsed. - # - # Raises a JSON::Stream::ParserError if the character does not belong - # in the expected keyword. - # - # Returns nothing. - def keyword(word, value, re, ch) - if ch =~ re - @buf << ch - else - error("Expected #{word} keyword") - end - - if @buf.size == word.size - if @buf == word - @buf = +"" - end_value(value) - else - error("Expected #{word} keyword") - end - end - end - - # Process the first character of one of the seven possible JSON - # values: object, array, string, true, false, null, number. - # - # ch - The current character String. - # - # Raises a JSON::Stream::ParserError if the character does not signal - # the start of a value. - # - # Returns nothing. - def start_value(ch) - case ch - when LEFT_BRACE - notify(:start_document) if @stack.empty? - @state = :start_object - @stack.push(:object) - notify(:start_object) - when LEFT_BRACKET - notify(:start_document) if @stack.empty? - @state = :start_array - @stack.push(:array) - notify(:start_array) - when QUOTE - @state = :start_string - @stack.push(:string) - when T - @state = :start_true - @buf << ch - when F - @state = :start_false - @buf << ch - when N - @state = :start_null - @buf << ch - when MINUS - @state = :start_negative_number - @buf << ch - when ZERO - @state = :start_zero - @buf << ch - when DIGIT_1_9 - @state = :start_int - @buf << ch - when WS - # ignore - else - error("Expected value") - end - end - - # Advance the state machine and notify `value` observers that a - # string, number or keyword (true, false, null) value was parsed. - # - # value - The object to broadcast to observers. - # - # Returns nothing. - def end_value(value) - @state = :end_value - notify(:start_document) if @stack.empty? - notify(:value, value) - notify_end_document if @stack.empty? - end - - def error(message) - raise ParserError, "#{message}: char #{@pos}" - end - end - end -end diff --git a/lib/completions/json_streaming_tracker.rb b/lib/completions/json_streaming_tracker.rb deleted file mode 100644 index 26771e9f..00000000 --- a/lib/completions/json_streaming_tracker.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - class JsonStreamingTracker - attr_reader :current_key, :current_value, :stream_consumer - - def initialize(stream_consumer) - @stream_consumer = stream_consumer - @current_key = nil - @current_value = nil - @tracking_array = false - @parser = DiscourseAi::Completions::JsonStreamingParser.new - - @parser.key do |k| - @current_key = k - @current_value = nil - end - - @parser.value do |value| - if @current_key - if @tracking_array - @current_value << value - stream_consumer.notify_progress(@current_key, @current_value) - else - stream_consumer.notify_progress(@current_key, value) - @current_key = nil - end - end - end - - @parser.start_array do - @tracking_array = true - @current_value = [] - end - - @parser.end_array do - @tracking_array = false - @current_key = nil - @current_value = nil - end - end - - def broken? - @broken - end - - def <<(raw_json) - # llm could send broken json - # in that case just deal with it later - # don't stream - return if @broken - - begin - @parser << raw_json - rescue DiscourseAi::Completions::ParserError - # Note: We're parsing JSON content that was itself embedded as a string inside another JSON object. - # During the outer JSON.parse, any escaped control characters (like "\\n") are unescaped to real characters ("\n"), - # which corrupts the inner JSON structure when passed to the parser here. - # To handle this, we retry parsing with the string JSON-escaped again (`.dump[1..-2]`) if the first attempt fails. - try_escape_and_parse(raw_json) - return if @broken - end - - if @parser.state == :start_string && @current_key - buffered = @tracking_array ? [@parser.buf] : @parser.buf - # this is is worth notifying - stream_consumer.notify_progress(@current_key, buffered) - end - - @current_key = nil if @parser.state == :end_value - end - - private - - def try_escape_and_parse(raw_json) - if !raw_json.is_a?(String) - @broken = true - return - end - # Escape the string as JSON and remove surrounding quotes - escaped_json = raw_json.dump[1..-2] - @parser << escaped_json - rescue DiscourseAi::Completions::ParserError - @broken = true - end - end - end -end diff --git a/lib/completions/llm.rb b/lib/completions/llm.rb deleted file mode 100644 index 70e84004..00000000 --- a/lib/completions/llm.rb +++ /dev/null @@ -1,438 +0,0 @@ -# frozen_string_literal: true - -# A facade that abstracts multiple LLMs behind a single interface. -# -# Internally, it consists of the combination of a dialect and an endpoint. -# After receiving a prompt using our generic format, it translates it to -# the target model and routes the completion request through the correct gateway. -# -# Use the .proxy method to instantiate an object. -# It chooses the correct dialect and endpoint for the model you want to interact with. -# -# Tests of modules that perform LLM calls can use .with_prepared_responses to return canned responses -# instead of relying on WebMock stubs like we did in the past. -# -module DiscourseAi - module Completions - class Llm - UNKNOWN_MODEL = Class.new(StandardError) - - class << self - def presets - # Sam: I am not sure if it makes sense to translate model names at all - @presets ||= - begin - [ - { - id: "anthropic", - models: [ - { - name: "claude-3-7-sonnet-latest", - tokens: 200_000, - display_name: "Claude 3.7 Sonnet", - input_cost: 3, - cached_input_cost: 0.30, - output_cost: 15, - }, - { - name: "claude-sonnet-4-0", - tokens: 200_000, - display_name: "Claude 4 Sonnet", - input_cost: 3, - cached_input_cost: 0.30, - output_cost: 15, - }, - { - name: "claude-3-5-haiku-latest", - tokens: 200_000, - display_name: "Claude 3.5 Haiku", - input_cost: 0.80, - cached_input_cost: 0.08, - output_cost: 4, - }, - { - name: "claude-opus-4-0", - tokens: 200_000, - display_name: "Claude 4 Opus", - input_cost: 15, - cached_input_cost: 1.50, - output_cost: 75, - }, - ], - tokenizer: DiscourseAi::Tokenizer::AnthropicTokenizer, - endpoint: "https://api.anthropic.com/v1/messages", - provider: "anthropic", - }, - { - id: "google", - models: [ - { - name: "gemini-2.5-pro", - tokens: 800_000, - endpoint: - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro", - display_name: "Gemini 2.5 Pro", - input_cost: 1.25, - oputput_cost: 10.0, - }, - { - name: "gemini-2.5-flash", - tokens: 800_000, - endpoint: - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash", - display_name: "Gemini 2.5 Flash", - input_cost: 0.30, - output_cost: 2.50, - }, - { - name: "gemini-2.0-flash", - tokens: 800_000, - endpoint: - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash", - display_name: "Gemini 2.0 Flash", - input_cost: 0.10, - output_cost: 0.40, - }, - { - name: "gemini-2.0-flash-lite", - tokens: 800_000, - endpoint: - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite", - display_name: "Gemini 2.0 Flash Lite", - input_cost: 0.075, - output_cost: 0.30, - }, - ], - tokenizer: DiscourseAi::Tokenizer::GeminiTokenizer, - provider: "google", - }, - { - id: "open_ai", - models: [ - { - name: "o4-mini", - tokens: 200_000, - display_name: "o4 Mini", - input_cost: 1.10, - cached_input_cost: 0.275, - output_cost: 4.40, - }, - { - name: "o3", - tokens: 200_000, - display_name: "o3", - input_cost: 2, - cached_input_cost: 0.5, - output_cost: 8, - }, - { - name: "gpt-4.1", - tokens: 800_000, - display_name: "GPT-4.1", - input_cost: 2, - cached_input_cost: 0.5, - output_cost: 8, - }, - { - name: "gpt-4.1-mini", - tokens: 800_000, - display_name: "GPT-4.1 Mini", - input_cost: 0.40, - cached_input_cost: 0.10, - output_cost: 1.60, - }, - { - name: "gpt-4.1-nano", - tokens: 800_000, - display_name: "GPT-4.1 Nano", - input_cost: 0.10, - cached_input_cost: 0.025, - output_cost: 0.40, - }, - ], - tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer, - endpoint: "https://api.openai.com/v1/chat/completions", - provider: "open_ai", - }, - { - id: "samba_nova", - models: [ - { - name: "Meta-Llama-3.3-70B-Instruct", - tokens: 131_072, - display_name: "Llama 3.3 70B", - input_cost: 0.60, - output_cost: 1.20, - }, - { - name: "Meta-Llama-3.1-8B-Instruct", - tokens: 16_384, - display_name: "Llama 3.1 8B", - input_cost: 0.1, - output_cost: 0.20, - }, - ], - tokenizer: DiscourseAi::Tokenizer::Llama3Tokenizer, - endpoint: "https://api.sambanova.ai/v1/chat/completions", - provider: "samba_nova", - }, - { - id: "mistral", - models: [ - { - name: "mistral-large-latest", - tokens: 128_000, - display_name: "Mistral Large", - }, - { - name: "pixtral-large-latest", - tokens: 128_000, - display_name: "Pixtral Large", - }, - ], - tokenizer: DiscourseAi::Tokenizer::MistralTokenizer, - endpoint: "https://api.mistral.ai/v1/chat/completions", - provider: "mistral", - }, - { - id: "open_router", - models: [ - { - name: "x-ai/grok-3-beta", - tokens: 131_072, - display_name: "xAI Grok 3 Beta", - input_cost: 3, - output_cost: 15, - }, - { - name: "deepseek/deepseek-r1-0528:free", - tokens: 163_000, - display_name: "DeepSeek R1 0528 - free", - }, - { - name: "meta-llama/llama-3.3-70b-instruct", - tokens: 131_072, - display_name: "Llama 3.3 70B Instruct", - input_cost: 0.05, - output_cost: 0.25, - }, - ], - tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer, - endpoint: "https://openrouter.ai/api/v1/chat/completions", - provider: "open_router", - }, - ] - end - end - - def provider_names - providers = %w[ - aws_bedrock - anthropic - vllm - hugging_face - cohere - open_ai - google - azure - samba_nova - mistral - open_router - ] - if !Rails.env.production? - providers << "fake" - providers << "ollama" - end - - providers - end - - def tokenizer_names - DiscourseAi::Tokenizer::BasicTokenizer.available_llm_tokenizers.map(&:name) - end - - def valid_provider_models - return @valid_provider_models if defined?(@valid_provider_models) - - valid_provider_models = [] - models_by_provider.each do |provider, models| - valid_provider_models.concat(models.map { |model| "#{provider}:#{model}" }) - end - @valid_provider_models = Set.new(valid_provider_models) - end - - def with_prepared_responses(responses, llm: nil) - @canned_response = DiscourseAi::Completions::Endpoints::CannedResponse.new(responses) - @canned_llm = llm - @prompts = [] - @prompt_options = [] - - yield(@canned_response, llm, @prompts, @prompt_options) - ensure - # Don't leak prepared response if there's an exception. - @canned_response = nil - @canned_llm = nil - @prompts = nil - end - - def record_prompt(prompt, options) - @prompts << prompt.dup if @prompts - @prompt_options << options if @prompt_options - end - - def prompt_options - @prompt_options - end - - def prompts - @prompts - end - - def proxy(model) - llm_model = - if model.is_a?(LlmModel) - model - else - model_name_without_prov = model.split(":").last.to_i - - LlmModel.find_by(id: model_name_without_prov) - end - - raise UNKNOWN_MODEL if llm_model.nil? - - dialect_klass = DiscourseAi::Completions::Dialects::Dialect.dialect_for(llm_model) - - if @canned_response - if @canned_llm && @canned_llm != model - raise "Invalid call LLM call, expected #{@canned_llm} but got #{model}" - end - - return new(dialect_klass, nil, llm_model, gateway: @canned_response) - end - - model_provider = llm_model.provider - gateway_klass = DiscourseAi::Completions::Endpoints::Base.endpoint_for(model_provider) - - new(dialect_klass, gateway_klass, llm_model) - end - end - - def initialize(dialect_klass, gateway_klass, llm_model, gateway: nil) - @dialect_klass = dialect_klass - @gateway_klass = gateway_klass - @gateway = gateway - @llm_model = llm_model - end - - # @param generic_prompt { DiscourseAi::Completions::Prompt } - Our generic prompt object - # @param user { User } - User requesting the summary. - # @param temperature { Float - Optional } - The temperature to use for the completion. - # @param top_p { Float - Optional } - The top_p to use for the completion. - # @param max_tokens { Integer - Optional } - The maximum number of tokens to generate. - # @param stop_sequences { Array<String> - Optional } - The stop sequences to use for the completion. - # @param feature_name { String - Optional } - The feature name to use for the completion. - # @param feature_context { Hash - Optional } - The feature context to use for the completion. - # @param partial_tool_calls { Boolean - Optional } - If true, the completion will return partial tool calls. - # @param output_thinking { Boolean - Optional } - If true, the completion will return the thinking output for thinking models. - # @param response_format { Hash - Optional } - JSON schema passed to the API as the desired structured output. - # @param [Experimental] extra_model_params { Hash - Optional } - Other params that are not available accross models. e.g. response_format JSON schema. - # - # @param &on_partial_blk { Block - Optional } - The passed block will get called with the LLM partial response. - # - # @returns String | ToolCall - Completion result. - # if multiple tools or a tool and a message come back, the result will be an array of ToolCall / String objects. - # - def generate( - prompt, - temperature: nil, - top_p: nil, - max_tokens: nil, - stop_sequences: nil, - user:, - feature_name: nil, - feature_context: nil, - partial_tool_calls: false, - output_thinking: false, - response_format: nil, - extra_model_params: nil, - cancel_manager: nil, - &partial_read_blk - ) - self.class.record_prompt( - prompt, - { - temperature: temperature, - top_p: top_p, - max_tokens: max_tokens, - stop_sequences: stop_sequences, - user: user, - feature_name: feature_name, - feature_context: feature_context, - partial_tool_calls: partial_tool_calls, - output_thinking: output_thinking, - response_format: response_format, - extra_model_params: extra_model_params, - }, - ) - - model_params = { max_tokens: max_tokens, stop_sequences: stop_sequences } - - model_params[:temperature] = temperature if temperature - model_params[:top_p] = top_p if top_p - - # internals expect symbolized keys, so we normalize here - response_format = - JSON.parse(response_format.to_json, symbolize_names: true) if response_format && - response_format.is_a?(Hash) - - model_params[:response_format] = response_format if response_format - model_params.merge!(extra_model_params) if extra_model_params - - if prompt.is_a?(String) - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a helpful bot", - messages: [{ type: :user, content: prompt }], - ) - elsif prompt.is_a?(Array) - prompt = DiscourseAi::Completions::Prompt.new(messages: prompt) - end - - if !prompt.is_a?(DiscourseAi::Completions::Prompt) - raise ArgumentError, "Prompt must be either a string, array, of Prompt object" - end - - model_params.keys.each { |key| model_params.delete(key) if model_params[key].nil? } - - dialect = dialect_klass.new(prompt, llm_model, opts: model_params) - - gateway = @gateway || gateway_klass.new(llm_model) - gateway.perform_completion!( - dialect, - user, - model_params, - feature_name: feature_name, - feature_context: feature_context, - partial_tool_calls: partial_tool_calls, - output_thinking: output_thinking, - cancel_manager: cancel_manager, - &partial_read_blk - ) - end - - def max_prompt_tokens - llm_model.max_prompt_tokens - end - - def tokenizer - llm_model.tokenizer_class - end - - attr_reader :llm_model - - private - - attr_reader :dialect_klass, :gateway_klass - end - end -end diff --git a/lib/completions/nova_message_processor.rb b/lib/completions/nova_message_processor.rb deleted file mode 100644 index 80710c9c..00000000 --- a/lib/completions/nova_message_processor.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Completions::NovaMessageProcessor - class NovaToolCall - attr_reader :name, :raw_json, :id - - def initialize(name, id, partial_tool_calls: false) - @name = name - @id = id - @raw_json = +"" - @tool_call = DiscourseAi::Completions::ToolCall.new(id: id, name: name, parameters: {}) - @streaming_parser = - DiscourseAi::Completions::JsonStreamingTracker.new(self) if partial_tool_calls - end - - def append(json) - @raw_json << json - @streaming_parser << json if @streaming_parser - end - - def notify_progress(key, value) - @tool_call.partial = true - @tool_call.parameters[key.to_sym] = value - @has_new_data = true - end - - def has_partial? - @has_new_data - end - - def partial_tool_call - @has_new_data = false - @tool_call - end - - def to_tool_call - parameters = JSON.parse(raw_json, symbolize_names: true) - # we dupe to avoid poisoning the original tool call - @tool_call = @tool_call.dup - @tool_call.partial = false - @tool_call.parameters = parameters - @tool_call - end - end - - attr_reader :tool_calls, :input_tokens, :output_tokens - - def initialize(streaming_mode:, partial_tool_calls: false) - @streaming_mode = streaming_mode - @tool_calls = [] - @current_tool_call = nil - @partial_tool_calls = partial_tool_calls - end - - def to_tool_calls - @tool_calls.map { |tool_call| tool_call.to_tool_call } - end - - def process_streamed_message(parsed) - return if !parsed - - result = nil - - if tool_start = parsed.dig(:contentBlockStart, :start, :toolUse) - @current_tool_call = NovaToolCall.new(tool_start[:name], tool_start[:toolUseId]) - end - - if tool_progress = parsed.dig(:contentBlockDelta, :delta, :toolUse, :input) - @current_tool_call.append(tool_progress) - end - - result = @current_tool_call.to_tool_call if parsed[:contentBlockStop] && @current_tool_call - - if metadata = parsed[:metadata] - @input_tokens = metadata.dig(:usage, :inputTokens) - @output_tokens = metadata.dig(:usage, :outputTokens) - end - - result || parsed.dig(:contentBlockDelta, :delta, :text) - end - - def process_message(payload) - result = [] - parsed = payload - parsed = JSON.parse(payload, symbolize_names: true) if payload.is_a?(String) - - result << parsed.dig(:output, :message, :content, 0, :text) - - @input_tokens = parsed.dig(:usage, :inputTokens) - @output_tokens = parsed.dig(:usage, :outputTokens) - - result - end -end diff --git a/lib/completions/open_ai_message_processor.rb b/lib/completions/open_ai_message_processor.rb deleted file mode 100644 index 979c2043..00000000 --- a/lib/completions/open_ai_message_processor.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi::Completions - class OpenAiMessageProcessor - attr_reader :prompt_tokens, :completion_tokens, :cached_tokens - - def initialize(partial_tool_calls: false) - @tool = nil - @tool_arguments = +"" - @prompt_tokens = nil - @completion_tokens = nil - @cached_tokens = nil - @partial_tool_calls = partial_tool_calls - end - - def process_message(json) - result = [] - tool_calls = json.dig(:choices, 0, :message, :tool_calls) - - message = json.dig(:choices, 0, :message, :content) - result << message if message.present? - - if tool_calls.present? - tool_calls.each do |tool_call| - id = tool_call.dig(:id) - name = tool_call.dig(:function, :name) - arguments = tool_call.dig(:function, :arguments) - parameters = arguments.present? ? JSON.parse(arguments, symbolize_names: true) : {} - result << ToolCall.new(id: id, name: name, parameters: parameters) - end - end - - update_usage(json) - - result - end - - def process_streamed_message(json) - rval = nil - - tool_calls = json.dig(:choices, 0, :delta, :tool_calls) - content = json.dig(:choices, 0, :delta, :content) - - finished_tools = json.dig(:choices, 0, :finish_reason) || tool_calls == [] - - if tool_calls.present? - id = tool_calls.dig(0, :id) - name = tool_calls.dig(0, :function, :name) - arguments = tool_calls.dig(0, :function, :arguments) - - # TODO: multiple tool support may require index - #index = tool_calls[0].dig(:index) - - if id.present? && @tool && @tool.id != id - process_arguments - rval = @tool - @tool = nil - end - - if id.present? && name.present? - @tool_arguments = +"" - @tool = ToolCall.new(id: id, name: name) - @streaming_parser = JsonStreamingTracker.new(self) if @partial_tool_calls - end - - @tool_arguments << arguments.to_s - @streaming_parser << arguments.to_s if @streaming_parser && !arguments.to_s.empty? - rval = current_tool_progress if !rval - elsif finished_tools && @tool - parsed_args = JSON.parse(@tool_arguments, symbolize_names: true) - @tool.parameters = parsed_args - @tool.partial = false - rval = @tool - @tool = nil - elsif !content.to_s.empty? - # we don't want to strip empty content like "\n", do not use present? - rval = content - end - - update_usage(json) - - rval - end - - def notify_progress(key, value) - if @tool - @tool.partial = true - @tool.parameters[key.to_sym] = value - @has_new_data = true - end - end - - def current_tool_progress - if @has_new_data - @has_new_data = false - @tool - else - nil - end - end - - def finish - rval = [] - if @tool - process_arguments - rval << @tool - @tool = nil - end - - rval - end - - private - - def process_arguments - if @tool_arguments.present? - parsed_args = JSON.parse(@tool_arguments, symbolize_names: true) - @tool.parameters = parsed_args - @tool_arguments = nil - end - end - - def update_usage(json) - @prompt_tokens ||= json.dig(:usage, :prompt_tokens) - @completion_tokens ||= json.dig(:usage, :completion_tokens) - @cached_tokens ||= json.dig(:usage, :prompt_tokens_details, :cached_tokens) - end - end -end diff --git a/lib/completions/open_ai_responses_message_processor.rb b/lib/completions/open_ai_responses_message_processor.rb deleted file mode 100644 index 51381df7..00000000 --- a/lib/completions/open_ai_responses_message_processor.rb +++ /dev/null @@ -1,160 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi::Completions - class OpenAiResponsesMessageProcessor - attr_reader :prompt_tokens, :completion_tokens, :cached_tokens - - def initialize(partial_tool_calls: false) - @tool = nil # currently streaming ToolCall - @tool_arguments = +"" - @prompt_tokens = nil - @completion_tokens = nil - @cached_tokens = nil - @partial_tool_calls = partial_tool_calls - @streaming_parser = nil # JsonStreamingTracker, if used - @has_new_data = false - end - - # @param json [Hash] full JSON response from responses.create / retrieve - # @return [Array<String,ToolCall>] pieces in the order they were produced - def process_message(json) - result = [] - - (json[:output] || []).each do |item| - type = item[:type] - - case type - when "function_call" - result << build_tool_call_from_item(item) - when "message" - text = extract_text(item) - result << text if text - end - end - - update_usage(json) - result - end - - # @param json [Hash] a single streamed event, already parsed from ND-JSON - # @return [String, ToolCall, nil] only when a complete chunk is ready - def process_streamed_message(json) - rval = nil - event_type = json[:type] || json["type"] - - case event_type - when "response.output_text.delta" - delta = json[:delta] || json["delta"] - rval = delta if !delta.empty? - when "response.output_item.added" - item = json[:item] - if item && item[:type] == "function_call" - handle_tool_stream(:start, item) { |finished| rval = finished } - end - when "response.function_call_arguments.delta" - delta = json[:delta] - handle_tool_stream(:progress, delta) { |finished| rval = finished } if delta - when "response.output_item.done" - item = json[:item] - if item && item[:type] == "function_call" - handle_tool_stream(:done, item) { |finished| rval = finished } - end - end - - update_usage(json) - rval - end - - # Called by JsonStreamingTracker when partial JSON arguments are parsed - def notify_progress(key, value) - if @tool - @tool.partial = true - @tool.parameters[key.to_sym] = value - @has_new_data = true - end - end - - def current_tool_progress - if @has_new_data - @has_new_data = false - @tool - end - end - - def finish - rval = [] - if @tool - process_arguments - rval << @tool - @tool = nil - end - rval - end - - private - - def extract_text(message_item) - (message_item[:content] || message_item["content"] || []) - .filter { |c| (c[:type] || c["type"]) == "output_text" } - .map { |c| c[:text] || c["text"] } - .join - end - - def build_tool_call_from_item(item) - id = item[:call_id] - name = item[:name] - arguments = item[:arguments] || "" - params = arguments.empty? ? {} : JSON.parse(arguments, symbolize_names: true) - - ToolCall.new(id: id, name: name, parameters: params) - end - - def handle_tool_stream(event_type, json) - if event_type == :start - start_tool_stream(json) - elsif event_type == :progress - @streaming_parser << json if @streaming_parser - yield current_tool_progress - elsif event_type == :done - @tool_arguments << json[:arguments].to_s - process_arguments - finished = @tool - @tool = nil - yield finished - end - end - - def start_tool_stream(data) - # important note... streaming API has both id and call_id - # both seem to work as identifiers, api examples seem to favor call_id - # so I am using it here - id = data[:call_id] - name = data[:name] - - @tool_arguments = +"" - @tool = ToolCall.new(id: id, name: name) - @streaming_parser = JsonStreamingTracker.new(self) if @partial_tool_calls - end - - # Parse accumulated @tool_arguments once we have a complete JSON blob - def process_arguments - return if @tool_arguments.to_s.empty? - parsed = JSON.parse(@tool_arguments, symbolize_names: true) - @tool.parameters = parsed - @tool.partial = false - @tool_arguments = nil - rescue JSON::ParserError - # leave arguments empty; caller can decide how to handle - end - - def update_usage(json) - usage = json.dig(:response, :usage) - return if !usage - - cached_tokens = usage.dig(:input_tokens_details, :cached_tokens).to_i - - @prompt_tokens ||= usage[:input_tokens] - cached_tokens - @completion_tokens ||= usage[:output_tokens] - @cached_tokens ||= cached_tokens - end - end -end diff --git a/lib/completions/prompt.rb b/lib/completions/prompt.rb deleted file mode 100644 index 0641b64f..00000000 --- a/lib/completions/prompt.rb +++ /dev/null @@ -1,235 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - class Prompt - INVALID_TURN = Class.new(StandardError) - - attr_reader :messages, :tools, :system_message_text - attr_accessor :topic_id, :post_id, :max_pixels, :tool_choice - - def self.text_only(message) - if message[:content].is_a?(Array) - message[:content].map { |element| element if element.is_a?(String) }.compact.join - else - message[:content] - end - end - - def initialize( - system_message_text = nil, - messages: [], - tools: [], - topic_id: nil, - post_id: nil, - max_pixels: nil, - tool_choice: nil - ) - raise ArgumentError, "messages must be an array" if !messages.is_a?(Array) - raise ArgumentError, "tools must be an array" if !tools.is_a?(Array) - - @max_pixels = max_pixels || 1_048_576 - - @topic_id = topic_id - @post_id = post_id - - @messages = [] - - if system_message_text - @system_message_text = system_message_text - @messages << { type: :system, content: @system_message_text } - else - @system_message_text = messages.find { |m| m[:type] == :system }&.dig(:content) - end - - @messages.concat(messages) - - @messages.each { |message| validate_message(message) } - @messages.each_cons(2) { |last_turn, new_turn| validate_turn(last_turn, new_turn) } - - self.tools = tools - @tool_choice = tool_choice - end - - def tools=(tools) - raise ArgumentError, "tools must be an array" if !tools.is_a?(Array) && !tools.nil? - - @tools = - tools.map do |tool| - if tool.is_a?(Hash) - ToolDefinition.from_hash(tool) - elsif tool.is_a?(ToolDefinition) - tool - else - raise ArgumentError, "tool must be a hash or a ToolDefinition was #{tool.class}" - end - end - end - - # this new api tries to create symmetry between responses and prompts - # this means anything we get back from the model via endpoint can be easily appended - def push_model_response(response) - response = [response] if !response.is_a? Array - - thinking, thinking_signature, redacted_thinking_signature = nil - - response.each do |message| - if message.is_a?(Thinking) - # we can safely skip partials here - next if message.partial? - if message.redacted - redacted_thinking_signature = message.signature - else - thinking = message.message - thinking_signature = message.signature - end - elsif message.is_a?(ToolCall) - next if message.partial? - # this is a bit surprising about the API - # needing to add arguments is not ideal - push( - type: :tool_call, - content: { arguments: message.parameters }.to_json, - id: message.id, - name: message.name, - ) - elsif message.is_a?(String) - push(type: :model, content: message) - else - raise ArgumentError, "response must be an array of strings, ToolCalls, or Thinkings" - end - end - - # anthropic rules are that we attach thinking to last for the response - # it is odd, I wonder if long term we just keep thinking as a separate object - if thinking || redacted_thinking_signature - messages.last[:thinking] = thinking - messages.last[:thinking_signature] = thinking_signature - messages.last[:redacted_thinking_signature] = redacted_thinking_signature - end - end - - def push( - type:, - content:, - id: nil, - name: nil, - thinking: nil, - thinking_signature: nil, - redacted_thinking_signature: nil - ) - return if type == :system - new_message = { type: type, content: content } - new_message[:name] = name.to_s if name - new_message[:id] = id.to_s if id - new_message[:thinking] = thinking if thinking - new_message[:thinking_signature] = thinking_signature if thinking_signature - new_message[ - :redacted_thinking_signature - ] = redacted_thinking_signature if redacted_thinking_signature - - validate_message(new_message) - validate_turn(messages.last, new_message) - - messages << new_message - end - - def has_tools? - tools.present? - end - - def encoded_uploads(message) - if message[:content].is_a?(Array) - upload_ids = - message[:content] - .map do |content| - content[:upload_id] if content.is_a?(Hash) && content.key?(:upload_id) - end - .compact - if !upload_ids.empty? - return UploadEncoder.encode(upload_ids: upload_ids, max_pixels: max_pixels) - end - end - - [] - end - - def encode_upload(upload_id) - UploadEncoder.encode(upload_ids: [upload_id], max_pixels: max_pixels).first - end - - def content_with_encoded_uploads(content) - return [content] unless content.is_a?(Array) - - content.map do |c| - if c.is_a?(Hash) && c.key?(:upload_id) - encode_upload(c[:upload_id]) - else - c - end - end - end - - def ==(other) - return false unless other.is_a?(Prompt) - messages == other.messages && tools == other.tools && topic_id == other.topic_id && - post_id == other.post_id && max_pixels == other.max_pixels && - tool_choice == other.tool_choice - end - - def eql?(other) - self == other - end - - def hash - [messages, tools, topic_id, post_id, max_pixels, tool_choice].hash - end - - private - - def validate_message(message) - valid_types = %i[system user model tool tool_call] - if !valid_types.include?(message[:type]) - raise ArgumentError, "message type must be one of #{valid_types}" - end - - valid_keys = %i[ - type - content - id - name - thinking - thinking_signature - redacted_thinking_signature - ] - if (invalid_keys = message.keys - valid_keys).any? - raise ArgumentError, "message contains invalid keys: #{invalid_keys}" - end - - if message[:content].is_a?(Array) - message[:content].each do |content| - if !content.is_a?(String) && !(content.is_a?(Hash) && content.keys == [:upload_id]) - raise ArgumentError, "Array message content must be a string or {upload_id: ...} " - end - end - else - if !message[:content].is_a?(String) - raise ArgumentError, "Message content must be a string or an array" - end - end - end - - def validate_turn(last_turn, new_turn) - valid_types = %i[tool tool_call model user] - raise INVALID_TURN if !valid_types.include?(new_turn[:type]) - - if last_turn[:type] == :system && %i[tool tool_call model].include?(new_turn[:type]) - raise INVALID_TURN - end - - raise INVALID_TURN if new_turn[:type] == :tool && last_turn[:type] != :tool_call - raise INVALID_TURN if new_turn[:type] == :model && last_turn[:type] == :model - end - end - end -end diff --git a/lib/completions/prompt_messages_builder.rb b/lib/completions/prompt_messages_builder.rb deleted file mode 100644 index 2864846a..00000000 --- a/lib/completions/prompt_messages_builder.rb +++ /dev/null @@ -1,525 +0,0 @@ -# frozen_string_literal: true -# -module DiscourseAi - module Completions - class PromptMessagesBuilder - MAX_CHAT_UPLOADS = 5 - MAX_TOPIC_UPLOADS = 5 - attr_reader :chat_context_posts - attr_accessor :topic - - def self.messages_from_chat( - message, - channel:, - context_post_ids:, - max_messages:, - include_uploads:, - bot_user_ids:, - instruction_message: nil - ) - include_thread_titles = !channel.direct_message_channel? && !message.thread_id - - current_id = message.id - messages = nil - - if !message.thread_id && channel.direct_message_channel? - messages = [message] - elsif !channel.direct_message_channel? && !message.thread_id - messages = - Chat::Message - .joins("left join chat_threads on chat_threads.id = chat_messages.thread_id") - .where(chat_channel_id: channel.id) - .where( - "chat_messages.thread_id IS NULL OR chat_threads.original_message_id = chat_messages.id", - ) - .order(id: :desc) - .limit(max_messages) - .to_a - .reverse - end - - messages ||= - ChatSDK::Thread.last_messages( - thread_id: message.thread_id, - guardian: Discourse.system_user.guardian, - page_size: max_messages, - ) - - builder = new - - guardian = Guardian.new(message.user) - if context_post_ids - builder.set_chat_context_posts( - context_post_ids, - guardian, - include_uploads: include_uploads, - ) - end - - messages.each do |m| - # restore stripped message - m.message = instruction_message if m.id == current_id && instruction_message - - if bot_user_ids.include?(m.user_id) - builder.push(type: :model, content: m.message) - else - upload_ids = nil - upload_ids = m.uploads.map(&:id) if include_uploads && m.uploads.present? - mapped_message = m.message - - thread_title = nil - thread_title = m.thread&.title if include_thread_titles && m.thread_id - mapped_message = "(#{thread_title})\n#{m.message}" if thread_title - - if m.uploads.present? - mapped_message = - "#{mapped_message} -- uploaded(#{m.uploads.map(&:short_url).join(", ")})" - end - - builder.push( - type: :user, - content: mapped_message, - id: m.user.username, - upload_ids: upload_ids, - ) - end - end - - builder.to_a( - limit: max_messages, - style: channel.direct_message_channel? ? :chat_with_context : :chat, - ) - end - - def self.messages_from_post(post, style: nil, max_posts:, bot_usernames:, include_uploads:) - # Pay attention to the `post_number <= ?` here. - # We want to inject the last post as context because they are translated differently. - - post_types = [Post.types[:regular]] - post_types << Post.types[:whisper] if post.post_type == Post.types[:whisper] - - context = - post - .topic - .posts - .joins(:user) - .joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id") - .where("post_number <= ?", post.post_number) - .order("post_number desc") - .where("post_type in (?)", post_types) - .limit(max_posts) - .pluck( - "posts.raw", - "users.username", - "post_custom_prompts.custom_prompt", - "( - SELECT array_agg(ref.upload_id) - FROM upload_references ref - WHERE ref.target_type = 'Post' AND ref.target_id = posts.id - ) as upload_ids", - "posts.created_at", - ) - - builder = new - builder.topic = post.topic - - context.reverse_each do |raw, username, custom_prompt, upload_ids, created_at| - custom_prompt_translation = - Proc.new do |message| - # We can't keep backwards-compatibility for stored functions. - # Tool syntax requires a tool_call_id which we don't have. - if message[2] != "function" - custom_context = { - content: message[0], - type: message[2].present? ? message[2].to_sym : :model, - } - - custom_context[:id] = message[1] if custom_context[:type] != :model - custom_context[:name] = message[3] if message[3] - - thinking = message[4] - custom_context[:thinking] = thinking if thinking - custom_context[:created_at] = created_at - - builder.push(**custom_context) - end - end - - if custom_prompt.present? - custom_prompt.each(&custom_prompt_translation) - else - context = { content: raw, type: (bot_usernames.include?(username) ? :model : :user) } - - context[:id] = username if context[:type] == :user - - if upload_ids.present? && context[:type] == :user && include_uploads - context[:upload_ids] = upload_ids.compact - end - context[:created_at] = created_at - - builder.push(**context) - end - end - - builder.to_a(style: style || (post.topic.private_message? ? :bot : :topic)) - end - - def initialize - @raw_messages = [] - @timestamps = {} - end - - def set_chat_context_posts(post_ids, guardian, include_uploads:) - posts = [] - Post - .where(id: post_ids) - .order("id asc") - .each do |post| - next if !guardian.can_see?(post) - posts << post - end - if posts.present? - posts_context = [] - posts_context << "\nThis chat is in the context of the Discourse topic '#{posts[0].topic.title}':\n\n" - posts_context << "{{{\n" - posts.each do |post| - posts_context << "url: #{post.url}\n" - posts_context << "#{post.username}: #{post.raw}\n\n" - if include_uploads - post.uploads.each { |upload| posts_context << { upload_id: upload.id } } - end - end - posts_context << "}}}" - @chat_context_posts = posts_context - end - end - - def to_a(limit: nil, style: nil) - # topic and chat array are special, they are single messages that contain all history - return chat_array(limit: limit) if style == :chat - return topic_array if style == :topic - - # the rest of the styles can include multiple messages - result = valid_messages_array(@raw_messages) - prepend_chat_post_context(result) if style == :chat_with_context - - if limit - result[0..limit] - else - result - end - end - - def push(type:, content:, name: nil, upload_ids: nil, id: nil, thinking: nil, created_at: nil) - if !%i[user model tool tool_call system].include?(type) - raise ArgumentError, "type must be either :user, :model, :tool, :tool_call or :system" - end - raise ArgumentError, "upload_ids must be an array" if upload_ids && !upload_ids.is_a?(Array) - - content = [content, *upload_ids.map { |upload_id| { upload_id: upload_id } }] if upload_ids - message = { type: type, content: content } - message[:name] = name.to_s if name - message[:id] = id.to_s if id - if thinking - message[:thinking] = thinking["thinking"] if thinking["thinking"] - message[:thinking_signature] = thinking["thinking_signature"] if thinking[ - "thinking_signature" - ] - message[:redacted_thinking_signature] = thinking[ - "redacted_thinking_signature" - ] if thinking["redacted_thinking_signature"] - end - - @raw_messages << message - @timestamps[message] = created_at if created_at - - message - end - - private - - def valid_messages_array(messages) - result = [] - - # this will create a "valid" messages array - # 1. ensures we always start with a user message - # 2. ensures we always end with a user message - # 3. ensures we always interleave user and model messages - last_type = nil - messages.each do |message| - if message[:type] == :model && !message[:content] - message[:content] = "Reply cancelled by user." - end - - next if !last_type && message[:type] != :user - - if last_type == :tool_call && message[:type] != :tool - result.pop - last_type = result.length > 0 ? result[-1][:type] : nil - end - - next if message[:type] == :tool && last_type != :tool_call - - if message[:type] == last_type - # merge the message for :user message - # replace the message for other messages - last_message = result[-1] - - if message[:type] == :user - old_name = last_message.delete(:id) - last_message[:content] = ["#{old_name}: ", last_message[:content]].flatten if old_name - - new_content = message[:content] - new_content = ["#{message[:id]}: ", new_content].flatten if message[:id] - - if !last_message[:content].is_a?(Array) - last_message[:content] = [last_message[:content]] - end - last_message[:content].concat(["\n", new_content].flatten) - - compressed = - compress_messages_buffer(last_message[:content], max_uploads: MAX_TOPIC_UPLOADS) - last_message[:content] = compressed - else - last_message[:content] = message[:content] - end - else - result << message - end - - last_type = message[:type] - end - - result - end - - def prepend_chat_post_context(messages) - return if @chat_context_posts.blank? - - old_content = messages[0][:content] - old_content = [old_content] if !old_content.is_a?(Array) - - new_content = [] - new_content << "You are replying inside a Discourse chat.\n" - new_content.concat(@chat_context_posts) - new_content << "\n" - new_content << "Your instructions are:\n" - new_content.concat(old_content) - - compressed = compress_messages_buffer(new_content.flatten, max_uploads: MAX_CHAT_UPLOADS) - - messages[0][:content] = compressed - end - - def format_user_info(user) - info = [] - info << user_role(user) - info << "Trust level #{user.trust_level}" if user.trust_level > 0 - info << "#{account_age(user)}" - info << "#{user.user_stat.post_count} posts" if user.user_stat.post_count.to_i > 0 - "#{user.username} (#{user.name}): #{info.compact.join(", ")}" - end - - def format_timestamp(timestamp) - return nil unless timestamp - - time_diff = Time.now - timestamp - - if time_diff < 1.minute - "just now" - elsif time_diff < 1.hour - mins = (time_diff / 1.minute).round - "#{mins} #{mins == 1 ? "minute" : "minutes"} ago" - elsif time_diff < 1.day - hours = (time_diff / 1.hour).round - "#{hours} #{hours == 1 ? "hour" : "hours"} ago" - elsif time_diff < 7.days - days = (time_diff / 1.day).round - "#{days} #{days == 1 ? "day" : "days"} ago" - elsif time_diff < 30.days - weeks = (time_diff / 7.days).round - "#{weeks} #{weeks == 1 ? "week" : "weeks"} ago" - elsif time_diff < 365.days - months = (time_diff / 30.days).round - "#{months} #{months == 1 ? "month" : "months"} ago" - else - years = (time_diff / 365.days).round - "#{years} #{years == 1 ? "year" : "years"} ago" - end - end - - def user_role(user) - return "moderator" if user.moderator? - return "admin" if user.admin? - nil - end - - def account_age(user) - years = ((Time.now - user.created_at) / 1.year).round - months = ((Time.now - user.created_at) / 1.month).round % 12 - - output = [] - if years > 0 - output << years.to_s - output << "year" if years == 1 - output << "years" if years > 1 - end - if months > 0 - output << months.to_s - output << "month" if months == 1 - output << "months" if months > 1 - end - - if output.empty? - "new account" - else - "account age: " + output.join(" ") - end - end - - def format_topic_info(topic) - content_array = [] - - if topic.private_message? - content_array << "Private message info.\n" - else - content_array << "Topic information:\n" - end - - content_array << "- URL: #{topic.url}\n" - content_array << "- Title: #{topic.title}\n" - if SiteSetting.tagging_enabled - tags = topic.tags.pluck(:name) - tags -= DiscourseTagging.hidden_tag_names if tags.present? - content_array << "- Tags: #{tags.join(", ")}\n" if tags.present? - end - if !topic.private_message? - content_array << "- Category: #{topic.category.name}\n" if topic.category - end - content_array << "- Number of replies: #{topic.posts_count - 1}\n\n" - - content_array.join - end - - def format_user_infos(usernames) - content_array = [] - - if usernames.present? - users_details = - User - .where(username: usernames) - .includes(:user_stat) - .map { |user| format_user_info(user) } - .compact - content_array << "User information:\n" - content_array << "- #{users_details.join("\n- ")}\n\n" if users_details.present? - end - content_array.join - end - - def topic_array - raw_messages = @raw_messages.dup - content_array = [] - content_array << "You are operating in a Discourse forum.\n\n" - content_array << format_topic_info(@topic) if @topic - - if raw_messages.present? - usernames = - raw_messages.filter { |message| message[:type] == :user }.map { |message| message[:id] } - - content_array << format_user_infos(usernames) if usernames.present? - end - - last_user_message = raw_messages.pop - - if raw_messages.present? - content_array << "Here is the conversation so far:\n" - raw_messages.each do |message| - content_array << "#{message[:id] || "User"}: " - timestamp = @timestamps[message] - content_array << "(#{format_timestamp(timestamp)}) " if timestamp - content_array << message[:content] - content_array << "\n\n" - end - end - - if last_user_message - content_array << "Latest post is by #{last_user_message[:id] || "User"} who just posted:\n" - content_array << last_user_message[:content] - end - - content_array = - compress_messages_buffer(content_array.flatten, max_uploads: MAX_TOPIC_UPLOADS) - - user_message = { type: :user, content: content_array } - - [user_message] - end - - def chat_array(limit:) - if @raw_messages.length > 1 - buffer = [ - +"You are replying inside a Discourse chat channel. Here is a summary of the conversation so far:\n{{{", - ] - - @raw_messages[0..-2].each do |message| - buffer << "\n" - - if message[:type] == :user - buffer << "#{message[:id] || "User"}: " - else - buffer << "Bot: " - end - - buffer << message[:content] - end - - buffer << "\n}}}" - buffer << "\n\n" - buffer << "Your instructions:" - buffer << "\n" - end - - last_message = @raw_messages[-1] - buffer << "#{last_message[:id] || "User"}: " - buffer << last_message[:content] - - buffer = compress_messages_buffer(buffer.flatten, max_uploads: MAX_CHAT_UPLOADS) - - message = { type: :user, content: buffer } - [message] - end - - # caps uploads to maximum uploads allowed in message stream - # and concats string elements - def compress_messages_buffer(buffer, max_uploads:) - compressed = [] - current_text = +"" - upload_count = 0 - - buffer.each do |item| - if item.is_a?(String) - current_text << item - elsif item.is_a?(Hash) - compressed << current_text if current_text.present? - compressed << item - current_text = +"" - upload_count += 1 - end - end - - compressed << current_text if current_text.present? - - if upload_count > max_uploads - to_remove = upload_count - max_uploads - removed = 0 - compressed.delete_if { |item| item.is_a?(Hash) && (removed += 1) <= to_remove } - end - - compressed = compressed[0] if compressed.length == 1 && compressed[0].is_a?(String) - - compressed - end - end - end -end diff --git a/lib/completions/report.rb b/lib/completions/report.rb deleted file mode 100644 index e90ca12e..00000000 --- a/lib/completions/report.rb +++ /dev/null @@ -1,204 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module Completions - class Report - UNKNOWN_FEATURE = "unknown" - USER_LIMIT = 50 - - attr_reader :start_date, :end_date, :base_query - - def initialize(start_date: 30.days.ago, end_date: Time.current) - @start_date = start_date.beginning_of_day - @end_date = end_date.end_of_day - @base_query = AiApiAuditLog.where(created_at: @start_date..@end_date) - end - - def total_tokens - stats.total_tokens || 0 - end - - def total_cached_tokens - stats.total_cached_tokens || 0 - end - - def total_request_tokens - stats.total_request_tokens || 0 - end - - def total_response_tokens - stats.total_response_tokens || 0 - end - - def total_requests - stats.total_requests || 0 - end - - def total_spending - total = total_input_spending + total_output_spending + total_cached_input_spending - total.round(2) - end - - def total_input_spending - model_costs.sum { |row| row.input_cost.to_f * row.total_request_tokens.to_i / 1_000_000.0 } - end - - def total_output_spending - model_costs.sum do |row| - row.output_cost.to_f * row.total_response_tokens.to_i / 1_000_000.0 - end - end - - def total_cached_input_spending - model_costs.sum do |row| - row.cached_input_cost.to_f * row.total_cached_tokens.to_i / 1_000_000.0 - end - end - - def stats - @stats ||= - base_query.select( - "COUNT(*) as total_requests", - "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", - "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(COALESCE(request_tokens,0)) as total_request_tokens", - "SUM(COALESCE(response_tokens,0)) as total_response_tokens", - )[ - 0 - ] - end - - def model_costs - @model_costs ||= - base_query - .joins("LEFT JOIN llm_models ON llm_models.name = language_model") - .group( - "llm_models.name, llm_models.input_cost, llm_models.output_cost, llm_models.cached_input_cost", - ) - .select( - "llm_models.name", - "llm_models.input_cost", - "llm_models.output_cost", - "llm_models.cached_input_cost", - "SUM(COALESCE(request_tokens, 0)) as total_request_tokens", - "SUM(COALESCE(response_tokens, 0)) as total_response_tokens", - "SUM(COALESCE(cached_tokens, 0)) as total_cached_tokens", - ) - end - - def guess_period(period = nil) - period = nil if %i[day month hour].include?(period) - period || - case @end_date - @start_date - when 0..3.days - :hour - when 3.days..90.days - :day - else - :month - end - end - - def tokens_by_period(period = nil) - period = guess_period(period) - base_query - .group("DATE_TRUNC('#{period}', created_at)") - .order("DATE_TRUNC('#{period}', created_at)") - .select( - "DATE_TRUNC('#{period}', created_at) as period", - "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", - "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(COALESCE(request_tokens,0)) as total_request_tokens", - "SUM(COALESCE(response_tokens,0)) as total_response_tokens", - ) - end - - def user_breakdown - base_query - .joins(:user) - .joins("LEFT JOIN llm_models ON llm_models.name = language_model") - .group(:user_id, "users.username", "users.uploaded_avatar_id") - .order("usage_count DESC") - .limit(USER_LIMIT) - .select( - "users.username", - "users.uploaded_avatar_id", - "COUNT(*) as usage_count", - "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", - "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(COALESCE(request_tokens,0)) as total_request_tokens", - "SUM(COALESCE(response_tokens,0)) as total_response_tokens", - "SUM(COALESCE(request_tokens, 0) * COALESCE(llm_models.input_cost, 0)) / 1000000.0 as input_spending", - "SUM(COALESCE(response_tokens, 0) * COALESCE(llm_models.output_cost, 0)) / 1000000.0 as output_spending", - "SUM(COALESCE(cached_tokens, 0) * COALESCE(llm_models.cached_input_cost, 0)) / 1000000.0 as cached_input_spending", - ) - end - - def feature_breakdown - base_query - .joins("LEFT JOIN llm_models ON llm_models.name = language_model") - .group(:feature_name) - .order("usage_count DESC") - .select( - "case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name", - "COUNT(*) as usage_count", - "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", - "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(COALESCE(request_tokens,0)) as total_request_tokens", - "SUM(COALESCE(response_tokens,0)) as total_response_tokens", - "SUM(COALESCE(request_tokens, 0) * COALESCE(llm_models.input_cost, 0)) / 1000000.0 as input_spending", - "SUM(COALESCE(response_tokens, 0) * COALESCE(llm_models.output_cost, 0)) / 1000000.0 as output_spending", - "SUM(COALESCE(cached_tokens, 0) * COALESCE(llm_models.cached_input_cost, 0)) / 1000000.0 as cached_input_spending", - ) - end - - def model_breakdown - base_query - .joins("LEFT JOIN llm_models ON llm_models.name = language_model") - .group( - :language_model, - "llm_models.input_cost", - "llm_models.output_cost", - "llm_models.cached_input_cost", - ) - .order("usage_count DESC") - .select( - "language_model as llm", - "COUNT(*) as usage_count", - "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", - "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(COALESCE(request_tokens,0)) as total_request_tokens", - "SUM(COALESCE(response_tokens,0)) as total_response_tokens", - "SUM(COALESCE(request_tokens, 0)) * COALESCE(llm_models.input_cost, 0) / 1000000.0 as input_spending", - "SUM(COALESCE(response_tokens, 0)) * COALESCE(llm_models.output_cost, 0) / 1000000.0 as output_spending", - "SUM(COALESCE(cached_tokens, 0)) * COALESCE(llm_models.cached_input_cost, 0) / 1000000.0 as cached_input_spending", - ) - end - - def tokens_per_hour - tokens_by_period(:hour) - end - - def tokens_per_day - tokens_by_period(:day) - end - - def tokens_per_month - tokens_by_period(:month) - end - - def filter_by_feature(feature_name) - if feature_name == UNKNOWN_FEATURE - @base_query = base_query.where("coalesce(feature_name, '') = ''") - else - @base_query = base_query.where(feature_name: feature_name) - end - self - end - - def filter_by_model(model_name) - @base_query = base_query.where(language_model: model_name) - self - end - end - end -end diff --git a/lib/completions/structured_output.rb b/lib/completions/structured_output.rb deleted file mode 100644 index b2e39b8f..00000000 --- a/lib/completions/structured_output.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - class StructuredOutput - def initialize(json_schema_properties) - @property_names = json_schema_properties.keys.map(&:to_sym) - @property_cursors = - json_schema_properties.reduce({}) do |m, (k, prop)| - m[k.to_sym] = 0 if prop[:type] == "string" - m - end - - @tracked = {} - - @raw_response = +"" - @raw_cursor = 0 - - @partial_json_tracker = JsonStreamingTracker.new(self) - - @type_map = {} - json_schema_properties.each { |name, prop| @type_map[name.to_sym] = prop[:type].to_sym } - - @done = false - end - - def to_s - # we may want to also normalize the JSON here for the broken case - @raw_response - end - - attr_reader :last_chunk_buffer - - def <<(raw) - raise "Cannot append to a completed StructuredOutput" if @done - @raw_response << raw - @partial_json_tracker << raw - end - - def finish - @done = true - end - - def broken? - @partial_json_tracker.broken? - end - - def read_buffered_property(prop_name) - if @partial_json_tracker.broken? - if @done - return nil if @type_map[prop_name.to_sym].nil? - return( - DiscourseAi::Utils::BestEffortJsonParser.extract_key( - @raw_response, - @type_map[prop_name.to_sym], - prop_name, - ) - ) - else - return nil - end - end - - # Maybe we haven't read that part of the JSON yet. - return nil if @tracked[prop_name].nil? - - # This means this property is a string and we want to return unread chunks. - if @property_cursors[prop_name].present? - unread = @tracked[prop_name][@property_cursors[prop_name]..] - @property_cursors[prop_name] = @tracked[prop_name].length - unread - else - # Ints and bools, and arrays are always returned as is. - @tracked[prop_name] - end - end - - def notify_progress(key, value) - key_sym = key.to_sym - return if !@property_names.include?(key_sym) - - @tracked[key_sym] = value - end - end - end -end diff --git a/lib/completions/thinking.rb b/lib/completions/thinking.rb deleted file mode 100644 index eb9e6275..00000000 --- a/lib/completions/thinking.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - class Thinking - attr_accessor :message, :signature, :redacted, :partial - - def initialize(message:, signature: nil, redacted: false, partial: false) - @message = message - @signature = signature - @redacted = redacted - @partial = partial - end - - def partial? - !!@partial - end - - def ==(other) - message == other.message && signature == other.signature && redacted == other.redacted && - partial == other.partial - end - - def dup - Thinking.new( - message: message.dup, - signature: signature.dup, - redacted: redacted, - partial: partial, - ) - end - - def to_s - "#{message} - #{signature} - #{redacted} - #{partial}" - end - end - end -end diff --git a/lib/completions/tool_call.rb b/lib/completions/tool_call.rb deleted file mode 100644 index c3aa047b..00000000 --- a/lib/completions/tool_call.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - class ToolCall - attr_reader :id, :name, :parameters - attr_accessor :partial - - def partial? - !!@partial - end - - def initialize(id:, name:, parameters: nil) - @id = id - @name = name - self.parameters = parameters if parameters - @parameters ||= {} - @partial = false - end - - def parameters=(parameters) - raise ArgumentError, "parameters must be a hash" unless parameters.is_a?(Hash) - @parameters = parameters.symbolize_keys - end - - def ==(other) - id == other.id && name == other.name && parameters == other.parameters - end - - def to_s - "#{name} - #{id} (\n#{parameters.map(&:to_s).join("\n")}\n)" - end - - def dup - call = ToolCall.new(id: id, name: name, parameters: parameters.deep_dup) - call.partial = partial - call - end - end - end -end diff --git a/lib/completions/tool_definition.rb b/lib/completions/tool_definition.rb deleted file mode 100644 index f3a49b4d..00000000 --- a/lib/completions/tool_definition.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - class ToolDefinition - class ParameterDefinition - ALLOWED_TYPES = %i[string boolean integer array number].freeze - ALLOWED_KEYS = %i[name description type required enum item_type].freeze - - attr_reader :name, :description, :type, :required, :enum, :item_type - - def self.from_hash(hash) - extra_keys = hash.keys - ALLOWED_KEYS - if !extra_keys.empty? - raise ArgumentError, "Unexpected keys in parameter definition: #{extra_keys}" - end - - new( - name: hash[:name], - description: hash[:description], - type: hash[:type], - required: hash[:required], - enum: hash[:enum], - item_type: hash[:item_type], - ) - end - - def initialize(name:, description:, type:, required: false, enum: nil, item_type: nil) - raise ArgumentError, "name must be a string" if !name.is_a?(String) || name.empty? - - if !description.is_a?(String) || description.empty? - raise ArgumentError, "description must be a string" - end - - type_sym = type.to_sym - - if !ALLOWED_TYPES.include?(type_sym) - raise ArgumentError, "type must be one of: #{ALLOWED_TYPES.join(", ")}" - end - - # Validate enum if provided - if enum - raise ArgumentError, "enum must be an array" if !enum.is_a?(Array) - - # Validate enum entries match the specified type - enum.each do |value| - case type_sym - when :string - if !value.is_a?(String) - raise ArgumentError, "enum values must be strings for type 'string'" - end - when :boolean - if ![true, false].include?(value) - raise ArgumentError, "enum values must be booleans for type 'boolean'" - end - when :integer - if !value.is_a?(Integer) - raise ArgumentError, "enum values must be integers for type 'integer'" - end - when :number - if !value.is_a?(Numeric) - raise ArgumentError, "enum values must be numbers for type 'number'" - end - when :array - if !value.is_a?(Array) - raise ArgumentError, "enum values must be arrays for type 'array'" - end - end - end - end - - if item_type && type_sym != :array - raise ArgumentError, "item_type can only be specified for array type" - end - - if item_type - if !ALLOWED_TYPES.include?(item_type.to_sym) - raise ArgumentError, "item type must be one of: #{ALLOWED_TYPES.join(", ")}" - end - end - - @name = name - @description = description - @type = type_sym - @required = !!required - @enum = enum - @item_type = item_type ? item_type.to_sym : nil - end - - def to_h - result = { name: @name, description: @description, type: @type, required: @required } - result[:enum] = @enum if @enum - result[:item_type] = @item_type if @item_type - result - end - end - - def parameters_json_schema - properties = {} - required = [] - - result = { type: "object", properties: properties, required: required } - - parameters.each do |param| - name = param.name - required << name if param.required - properties[name] = { type: param.type, description: param.description } - properties[name][:items] = { type: param.item_type } if param.item_type - properties[name][:enum] = param.enum if param.enum - end - - result - end - - attr_reader :name, :description, :parameters - - def self.from_hash(hash) - allowed_keys = %i[name description parameters] - extra_keys = hash.keys - allowed_keys - if !extra_keys.empty? - raise ArgumentError, "Unexpected keys in tool definition: #{extra_keys}" - end - - params = hash[:parameters] || [] - parameter_objects = - params.map do |param| - if param.is_a?(Hash) - ParameterDefinition.from_hash(param) - else - param - end - end - - new(name: hash[:name], description: hash[:description], parameters: parameter_objects) - end - - def initialize(name:, description:, parameters: []) - raise ArgumentError, "name must be a string" if !name.is_a?(String) || name.empty? - - if !description.is_a?(String) || description.empty? - raise ArgumentError, "description must be a string" - end - - raise ArgumentError, "parameters must be an array" if !parameters.is_a?(Array) - - # Check for duplicated parameter names - param_names = parameters.map { |p| p.name } - duplicates = param_names.select { |param_name| param_names.count(param_name) > 1 }.uniq - if !duplicates.empty? - raise ArgumentError, "Duplicate parameter names found: #{duplicates.join(", ")}" - end - - @name = name - @description = description - @parameters = parameters - end - - def to_h - { name: @name, description: @description, parameters: @parameters.map(&:to_h) } - end - - def coerce_parameters(params) - result = {} - - return result if !params.is_a?(Hash) - - @parameters.each do |param_def| - param_name = param_def.name.to_sym - - # Skip if parameter is not provided and not required - next if !params.key?(param_name) && !param_def.required - - # Handle required but missing parameters - if !params.key?(param_name) && param_def.required - result[param_name] = nil - next - end - - value = params[param_name] - - # For array type, handle item coercion - if param_def.type == :array - result[param_name] = coerce_array_value(value, param_def.item_type) - else - result[param_name] = coerce_single_value(value, param_def.type) - end - end - - result - end - - private - - def coerce_array_value(value, item_type) - # Handle non-array input by attempting to parse JSON strings - if !value.is_a?(Array) - if value.is_a?(String) - begin - parsed = JSON.parse(value) - value = parsed.is_a?(Array) ? parsed : nil - rescue JSON::ParserError - return nil - end - else - return nil - end - end - - # No item type specified, return the array as is - return value if !item_type - - # Coerce each item in the array - value.map { |item| coerce_single_value(item, item_type) } - end - - def coerce_single_value(value, type) - result = nil - - case type - when :string - result = value.to_s - when :integer - if value.is_a?(Integer) - result = value - elsif value.is_a?(Float) - result = value.to_i - elsif value.is_a?(String) && value.match?(/\A-?\d+\z/) - result = value.to_i - end - when :number - if value.is_a?(Numeric) - result = value.to_f - elsif value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/) - result = value.to_f - end - when :boolean - if value == true || value == false - result = value - elsif value.is_a?(String) - if value.downcase == "true" - result = true - elsif value.downcase == "false" - result = false - end - end - end - - result - end - end - end -end diff --git a/lib/completions/upload_encoder.rb b/lib/completions/upload_encoder.rb deleted file mode 100644 index c2aecbaa..00000000 --- a/lib/completions/upload_encoder.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - class UploadEncoder - def self.encode(upload_ids:, max_pixels:) - uploads = [] - upload_ids.each do |upload_id| - upload = Upload.find(upload_id) - next if upload.blank? - next if upload.width.to_i == 0 || upload.height.to_i == 0 - - desired_extension = upload.extension - desired_extension = "png" if upload.extension == "gif" - desired_extension = "png" if upload.extension == "webp" - desired_extension = "jpeg" if upload.extension == "jpg" - - # this keeps it very simple format wise given everyone supports png and jpg - next if !%w[jpeg png].include?(desired_extension) - - original_pixels = upload.width * upload.height - - image = upload - - if original_pixels > max_pixels - ratio = max_pixels.to_f / original_pixels - - new_width = (ratio * upload.width).to_i - new_height = (ratio * upload.height).to_i - - image = upload.get_optimized_image(new_width, new_height, format: desired_extension) - elsif upload.extension != desired_extension - image = - upload.get_optimized_image(upload.width, upload.height, format: desired_extension) - end - - next if !image - - mime_type = MiniMime.lookup_by_filename("test.#{desired_extension}").content_type - - path = Discourse.store.path_for(image) - if path.blank? - # download is protected with a DistributedMutex - external_copy = Discourse.store.download_safe(image) - path = external_copy&.path - end - - encoded = Base64.strict_encode64(File.read(path)) - - uploads << { base64: encoded, mime_type: mime_type } - end - uploads - end - end - end -end diff --git a/lib/completions/xml_tag_stripper.rb b/lib/completions/xml_tag_stripper.rb deleted file mode 100644 index c1e6e641..00000000 --- a/lib/completions/xml_tag_stripper.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Completions - class XmlTagStripper - def initialize(tags_to_strip) - @tags_to_strip = tags_to_strip - @longest_tag = tags_to_strip.map(&:length).max - @parsed = [] - end - - def <<(text) - if node = @parsed[-1] - if node[:type] == :maybe_tag - @parsed.pop - text = node[:content] + text - end - end - @parsed.concat(parse_tags(text)) - - @parsed, result = process_parsed(@parsed) - result - end - - def finish - @parsed.map { |node| node[:content] }.join - end - - def process_parsed(parsed) - output = [] - buffer = [] - stack = [] - - parsed.each do |node| - case node[:type] - when :text - if stack.empty? - output << node[:content] - else - buffer << node - end - when :open_tag - stack << node[:name] - buffer << node - when :close_tag - if stack.empty? - output << node[:content] - else - if stack[0] == node[:name] - buffer = [] - stack = [] - else - buffer << node - end - end - when :maybe_tag - buffer << node - end - end - - result = output.join - result = nil if result.empty? - - [buffer, result] - end - - def parse_tags(text) - parsed = [] - - while true - before, after = text.split("<", 2) - - parsed << { type: :text, content: before } if before && !before.empty? - - break if after.nil? - - if before.empty? && after.empty? - parsed << { type: :maybe_tag, content: "<" } - break - end - - tag, after = after.split(">", 2) - - is_end_tag = tag[0] == "/" - tag_name = tag - tag_name = tag[1..-1] || "" if is_end_tag - - if !after - found = false - if tag_name.length <= @longest_tag - @tags_to_strip.each do |tag_to_strip| - if tag_to_strip.start_with?(tag_name) - parsed << { type: :maybe_tag, content: "<" + tag } - found = true - break - end - end - end - parsed << { type: :text, content: "<" + tag } if !found - break - end - - raw_tag = "<" + tag + ">" - - if @tags_to_strip.include?(tag_name) - parsed << { - type: is_end_tag ? :close_tag : :open_tag, - content: raw_tag, - name: tag_name, - } - else - parsed << { type: :text, content: raw_tag } - end - text = after - end - - parsed - end - end - end -end diff --git a/lib/completions/xml_tool_processor.rb b/lib/completions/xml_tool_processor.rb deleted file mode 100644 index f20409f7..00000000 --- a/lib/completions/xml_tool_processor.rb +++ /dev/null @@ -1,221 +0,0 @@ -# frozen_string_literal: true - -# This class can be used to process a stream of text that may contain XML tool -# calls. -# It will return either text or ToolCall objects. - -module DiscourseAi - module Completions - class XmlToolProcessor - def initialize(partial_tool_calls: false, tool_definitions: nil) - @buffer = +"" - @function_buffer = +"" - @should_cancel = false - @in_tool = false - @partial_tool_calls = partial_tool_calls - @partial_tools = [] if @partial_tool_calls - @tool_definitions = tool_definitions - end - - def <<(text) - @buffer << text - result = [] - - if !@in_tool - # double check if we are clearly in a tool - search_length = text.length + 20 - search_string = @buffer[-search_length..-1] || @buffer - - index = search_string.rindex("<function_calls>") - @in_tool = !!index - if @in_tool - @function_buffer = @buffer[index..-1] - text_index = text.rindex("<function_calls>") - result << text[0..text_index - 1].rstrip if text_index && text_index > 0 - end - else - add_to_function_buffer(text) - end - - if !@in_tool - if maybe_has_tool?(@buffer) - split_index = text.rindex("<").to_i - 1 - if split_index >= 0 - @function_buffer = text[split_index + 1..-1] || "" - text = text[0..split_index] || "" - else - add_to_function_buffer(text) - text = "" - end - else - if @function_buffer.length > 0 - result << @function_buffer - @function_buffer = +"" - end - end - - result << text if text.length > 0 - else - @should_cancel = true if text.include?("</function_calls>") - end - - if @should_notify_partial_tool - @should_notify_partial_tool = false - result << @partial_tools.last - end - - result - end - - def finish - return [] if @function_buffer.blank? - - idx = -1 - parse_malformed_xml(@function_buffer).map do |tool| - new_tool_call( - id: "tool_#{idx += 1}", - name: tool[:tool_name], - parameters: tool[:parameters], - ) - end - end - - def should_cancel? - @should_cancel - end - - private - - def new_tool_call(id:, name:, parameters:) - if tool_def = @tool_definitions&.find { |d| d.name == name } - parameters = tool_def.coerce_parameters(parameters) - end - ToolCall.new(id:, name:, parameters:) - end - - def add_to_function_buffer(text) - @function_buffer << text - detect_partial_tool_calls(@function_buffer, text) if @partial_tool_calls - end - - def detect_partial_tool_calls(buffer, delta) - parse_partial_tool_call(buffer) - end - - def parse_partial_tool_call(buffer) - match = - buffer - .scan( - %r{ - <invoke> - \s* - <tool_name> - ([^<]+) - </tool_name> - \s* - <parameters> - (.*?) - (</parameters>|\Z) - }mx, - ) - .to_a - .last - - if match - params = partial_parse_params(match[1]) - if params.present? - current_tool = @partial_tools.last - if !current_tool || current_tool.name != match[0].strip - current_tool = - new_tool_call( - id: "tool_#{@partial_tools.length}", - name: match[0].strip, - parameters: params, - ) - @partial_tools << current_tool - current_tool.partial = true - @should_notify_partial_tool = true - end - - if current_tool.parameters != params - current_tool.parameters = params - @should_notify_partial_tool = true - end - end - end - end - - def partial_parse_params(params) - params - .scan(%r{ - <([^>]+)> - (.*?) - (</\1>|\Z) - }mx) - .each_with_object({}) do |(name, value), hash| - next if "<![CDATA[".start_with?(value) - hash[name.to_sym] = value.gsub(/^<!\[CDATA\[|\]\]>$/, "") - end - end - - def parse_malformed_xml(input) - input - .scan( - %r{ - <invoke> - \s* - <tool_name> - ([^<]+) - </tool_name> - \s* - <parameters> - (.*?) - </parameters> - \s* - </invoke> - }mx, - ) - .map do |tool_name, params| - { - tool_name: tool_name.strip, - parameters: - params - .scan(%r{ - <([^>]+)> - (.*?) - </\1> - }mx) - .each_with_object({}) do |(name, value), hash| - hash[name.to_sym] = value.gsub(/^<!\[CDATA\[|\]\]>$/, "") - end, - } - end - end - - def normalize_function_ids!(function_buffer) - function_buffer - .css("invoke") - .each_with_index do |invoke, index| - if invoke.at("tool_id") - invoke.at("tool_id").content = "tool_#{index}" if invoke.at("tool_id").content.blank? - else - invoke.add_child("<tool_id>tool_#{index}</tool_id>\n") if !invoke.at("tool_id") - end - end - end - - def maybe_has_tool?(text) - # 16 is the length of function calls - substring = text[-16..-1] || text - split = substring.split("<") - - if split.length > 1 - match = "<" + split.last - "<function_calls>".start_with?(match) - else - substring.ends_with?("<") - end - end - end - end -end diff --git a/lib/configuration/embedding_defs_enumerator.rb b/lib/configuration/embedding_defs_enumerator.rb deleted file mode 100644 index b4adac1b..00000000 --- a/lib/configuration/embedding_defs_enumerator.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require "enum_site_setting" - -module DiscourseAi - module Configuration - class EmbeddingDefsEnumerator < ::EnumSiteSetting - def self.valid_value?(val) - true - end - - def self.values - DB.query_hash(<<~SQL).map(&:symbolize_keys) - SELECT display_name AS name, id AS value - FROM embedding_definitions - SQL - end - end - end -end diff --git a/lib/configuration/embedding_defs_validator.rb b/lib/configuration/embedding_defs_validator.rb deleted file mode 100644 index 6662da33..00000000 --- a/lib/configuration/embedding_defs_validator.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Configuration - class EmbeddingDefsValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(val) - val.present? || !SiteSetting.ai_embeddings_enabled - end - - def error_message - I18n.t("discourse_ai.embeddings.configuration.disable_embeddings") - end - end - end -end diff --git a/lib/configuration/embeddings_model_validator.rb b/lib/configuration/embeddings_model_validator.rb deleted file mode 100644 index 489f2656..00000000 --- a/lib/configuration/embeddings_model_validator.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Configuration - class EmbeddingsModelValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(val) - return true if Rails.env.test? - - representation = - DiscourseAi::Embeddings::VectorRepresentations::Base.find_representation(val) - - return false if representation.nil? - - if !representation.correctly_configured? - @representation = representation - return false - end - - if !can_generate_embeddings?(val) - @unreachable = true - return false - end - - true - end - - def error_message - return(I18n.t("discourse_ai.embeddings.configuration.model_unreachable")) if @unreachable - - @representation&.configuration_hint - end - - def can_generate_embeddings?(val) - vdef = DiscourseAi::Embeddings::VectorRepresentations::Base.find_representation(val).new - DiscourseAi::Embeddings::Vector.new(vdef).vector_from("this is a test").present? - end - end - end -end diff --git a/lib/configuration/embeddings_module_validator.rb b/lib/configuration/embeddings_module_validator.rb deleted file mode 100644 index cb320b30..00000000 --- a/lib/configuration/embeddings_module_validator.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Configuration - class EmbeddingsModuleValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(val) - return true if val == "f" - return true if Rails.env.test? - - SiteSetting.ai_embeddings_selected_model.present? - end - - def error_message - I18n.t("discourse_ai.embeddings.configuration.choose_model") - end - end - end -end diff --git a/lib/configuration/feature.rb b/lib/configuration/feature.rb deleted file mode 100644 index ee62e8c3..00000000 --- a/lib/configuration/feature.rb +++ /dev/null @@ -1,296 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Configuration - class Feature - class << self - def feature_cache - @feature_cache ||= ::DiscourseAi::MultisiteHash.new("feature_cache") - end - - def summarization_features - feature_cache[:summarization] ||= [ - new( - "topic_summaries", - "ai_summarization_persona", - DiscourseAi::Configuration::Module::SUMMARIZATION_ID, - DiscourseAi::Configuration::Module::SUMMARIZATION, - ), - new( - "gists", - "ai_summary_gists_persona", - DiscourseAi::Configuration::Module::SUMMARIZATION_ID, - DiscourseAi::Configuration::Module::SUMMARIZATION, - enabled_by_setting: "ai_summary_gists_enabled", - ), - ] - end - - def search_features - feature_cache[:search] ||= [ - new( - "discoveries", - "ai_bot_discover_persona", - DiscourseAi::Configuration::Module::SEARCH_ID, - DiscourseAi::Configuration::Module::SEARCH, - ), - ] - end - - def discord_features - feature_cache[:discord] ||= [ - new( - "search", - "ai_discord_search_persona", - DiscourseAi::Configuration::Module::DISCORD_ID, - DiscourseAi::Configuration::Module::DISCORD, - ), - ] - end - - def inference_features - feature_cache[:inference] ||= [ - new( - "generate_concepts", - "inferred_concepts_generate_persona", - DiscourseAi::Configuration::Module::INFERENCE_ID, - DiscourseAi::Configuration::Module::INFERENCE, - ), - new( - "match_concepts", - "inferred_concepts_match_persona", - DiscourseAi::Configuration::Module::INFERENCE_ID, - DiscourseAi::Configuration::Module::INFERENCE, - ), - new( - "deduplicate_concepts", - "inferred_concepts_deduplicate_persona", - DiscourseAi::Configuration::Module::INFERENCE_ID, - DiscourseAi::Configuration::Module::INFERENCE, - ), - ] - end - - def ai_helper_features - feature_cache[:ai_helper] ||= [ - new( - "proofread", - "ai_helper_proofreader_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ), - new( - "title_suggestions", - "ai_helper_title_suggestions_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ), - new( - "explain", - "ai_helper_explain_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ), - new( - "smart_dates", - "ai_helper_smart_dates_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ), - new( - "markdown_tables", - "ai_helper_markdown_tables_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ), - new( - "translator", - "ai_helper_translator_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ), - new( - "custom_prompt", - "ai_helper_custom_prompt_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ), - new( - "image_caption", - "ai_helper_image_caption_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ), - ] - end - - def bot_features - feature_cache[:bot] ||= [ - new( - "bot", - nil, - DiscourseAi::Configuration::Module::BOT_ID, - DiscourseAi::Configuration::Module::BOT, - persona_ids_lookup: -> { lookup_bot_persona_ids }, - llm_models_lookup: -> { lookup_bot_llms }, - ), - ] - end - - def spam_features - feature_cache[:spam] ||= [ - new( - "inspect_posts", - nil, - DiscourseAi::Configuration::Module::SPAM_ID, - DiscourseAi::Configuration::Module::SPAM, - persona_ids_lookup: -> { [AiModerationSetting.spam&.ai_persona_id].compact }, - llm_models_lookup: -> { [AiModerationSetting.spam&.llm_model].compact }, - ), - ] - end - - def embeddings_features - feature_cache[:embeddings] ||= [ - new( - "hyde", - "ai_embeddings_semantic_search_hyde_persona", - DiscourseAi::Configuration::Module::EMBEDDINGS_ID, - DiscourseAi::Configuration::Module::EMBEDDINGS, - ), - ] - end - - def lookup_bot_persona_ids - AiPersona - .where(enabled: true) - .where( - "allow_chat_channel_mentions OR allow_chat_direct_messages OR allow_topic_mentions OR allow_personal_messages", - ) - .pluck(:id) - end - - def lookup_bot_llms - LlmModel.where(enabled_chat_bot: true).to_a - end - - def translation_features - feature_cache[:translation] ||= [ - new( - "locale_detector", - "ai_translation_locale_detector_persona", - DiscourseAi::Configuration::Module::TRANSLATION_ID, - DiscourseAi::Configuration::Module::TRANSLATION, - ), - new( - "post_raw_translator", - "ai_translation_post_raw_translator_persona", - DiscourseAi::Configuration::Module::TRANSLATION_ID, - DiscourseAi::Configuration::Module::TRANSLATION, - ), - new( - "topic_title_translator", - "ai_translation_topic_title_translator_persona", - DiscourseAi::Configuration::Module::TRANSLATION_ID, - DiscourseAi::Configuration::Module::TRANSLATION, - ), - new( - "short_text_translator", - "ai_translation_short_text_translator_persona", - DiscourseAi::Configuration::Module::TRANSLATION_ID, - DiscourseAi::Configuration::Module::TRANSLATION, - ), - ] - end - - def all - [ - summarization_features, - search_features, - discord_features, - inference_features, - ai_helper_features, - translation_features, - bot_features, - spam_features, - embeddings_features, - ].flatten - end - - def find_features_using(persona_id:) - all.select { |feature| feature.persona_ids.include?(persona_id) } - end - end - - def initialize( - name, - persona_setting, - module_id, - module_name, - enabled_by_setting: "", - persona_ids_lookup: nil, - llm_models_lookup: nil - ) - @name = name - @persona_setting = persona_setting - @module_id = module_id - @module_name = module_name - @enabled_by_setting = enabled_by_setting - @persona_ids_lookup = persona_ids_lookup - @llm_models_lookup = llm_models_lookup - end - - def llm_models - return @llm_models_lookup.call if @llm_models_lookup - return if !persona_ids - - llm_models = [] - personas = AiPersona.where(id: persona_ids) - personas.each do |persona| - next if persona.blank? - - persona_klass = persona.class_instance - - llm_model = - case module_name - when DiscourseAi::Configuration::Module::SUMMARIZATION - DiscourseAi::Summarization.find_summarization_model(persona_klass) - when DiscourseAi::Configuration::Module::AI_HELPER - DiscourseAi::AiHelper::Assistant.find_ai_helper_model(name, persona_klass) - when DiscourseAi::Configuration::Module::TRANSLATION - DiscourseAi::Translation::BaseTranslator.preferred_llm_model(persona_klass) - when DiscourseAi::Configuration::Module::EMBEDDINGS - DiscourseAi::Embeddings::SemanticSearch.new(nil).find_ai_hyde_model(persona_klass) - end - - if llm_model.blank? && persona.default_llm_id - llm_model = LlmModel.find_by(id: persona.default_llm_id) - end - - llm_models << llm_model if llm_model - end - - llm_models.compact.uniq - end - - attr_reader :name, :persona_setting, :module_id, :module_name - - def enabled? - @enabled_by_setting.blank? || SiteSetting.get(@enabled_by_setting) - end - - def persona_ids - if @persona_ids_lookup - @persona_ids_lookup.call - else - id = SiteSetting.get(persona_setting).to_i - if id != 0 - [id] - else - [] - end - end - end - end - end -end diff --git a/lib/configuration/llm_dependency_validator.rb b/lib/configuration/llm_dependency_validator.rb deleted file mode 100644 index 0cf715fe..00000000 --- a/lib/configuration/llm_dependency_validator.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Configuration - class LlmDependencyValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(val) - return true if val == "f" - - if @opts[:name] == :ai_summarization_enabled || @opts[:name] == :ai_helper_enabled - has_llms = LlmModel.count > 0 - @no_llms_configured = !has_llms - has_llms - else - @llm_dependency_setting_name = - DiscourseAi::Configuration::LlmValidator.new.choose_llm_setting_for(@opts[:name]) - - SiteSetting.public_send(@llm_dependency_setting_name).present? - end - end - - def error_message - if @llm_dependency_setting_name - I18n.t( - "discourse_ai.llm.configuration.set_llm_first", - setting: @llm_dependency_setting_name, - ) - elsif @no_llms_configured - I18n.t("discourse_ai.llm.configuration.create_llm") - end - end - end - end -end diff --git a/lib/configuration/llm_enumerator.rb b/lib/configuration/llm_enumerator.rb deleted file mode 100644 index 200fc0a2..00000000 --- a/lib/configuration/llm_enumerator.rb +++ /dev/null @@ -1,115 +0,0 @@ -# frozen_string_literal: true - -require "enum_site_setting" - -module DiscourseAi - module Configuration - class LlmEnumerator < ::EnumSiteSetting - def self.global_usage - rval = Hash.new { |h, k| h[k] = [] } - - if SiteSetting.ai_bot_enabled - LlmModel - .where("enabled_chat_bot = ?", true) - .pluck(:id) - .each { |llm_id| rval[llm_id] << { type: :ai_bot } } - end - - # this is unconditional, so it is clear that we always signal configuration - AiPersona - .where("default_llm_id IS NOT NULL") - .pluck(:default_llm_id, :name, :id) - .each { |llm_id, name, id| rval[llm_id] << { type: :ai_persona, name: name, id: id } } - - if SiteSetting.ai_helper_enabled - model_id = SiteSetting.ai_helper_model.split(":").last.to_i - rval[model_id] << { type: :ai_helper } if model_id != 0 - end - - if SiteSetting.ai_helper_image_caption_model - model_id = SiteSetting.ai_helper_image_caption_model.split(":").last.to_i - rval[model_id] << { type: :ai_helper_image_caption } if model_id != 0 - end - - if SiteSetting.ai_summarization_enabled - summarization_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona) - model_id = summarization_persona.default_llm_id || LlmModel.last&.id - - rval[model_id] << { type: :ai_summarization } - end - - if SiteSetting.ai_embeddings_semantic_search_enabled - model_id = SiteSetting.ai_embeddings_semantic_search_hyde_model.split(":").last.to_i - rval[model_id] << { type: :ai_embeddings_semantic_search } - end - - if SiteSetting.ai_spam_detection_enabled && AiModerationSetting.spam.present? - model_id = AiModerationSetting.spam[:llm_model_id] - rval[model_id] << { type: :ai_spam } - end - - if defined?(DiscourseAutomation::Automation) - DiscourseAutomation::Automation - .joins(:fields) - .where(script: %w[llm_report llm_triage]) - .where("discourse_automation_fields.name = ?", "model") - .pluck( - "metadata ->> 'value', discourse_automation_automations.name, discourse_automation_automations.id", - ) - .each do |model_text, name, id| - next if model_text.blank? - model_id = model_text.split("custom:").last.to_i - if model_id.present? - if model_text =~ /custom:(\d+)/ - rval[model_id] << { type: :automation, name: name, id: id } - end - end - end - end - - rval - end - - def self.valid_value?(val) - true - end - - # returns an array of hashes (id: , name:, vision_enabled:) - def self.values_for_serialization(allowed_seeded_llm_ids: nil) - builder = DB.build(<<~SQL) - SELECT id, display_name AS name, vision_enabled - FROM llm_models - /*where*/ - SQL - - if allowed_seeded_llm_ids.is_a?(Array) && !allowed_seeded_llm_ids.empty? - builder.where( - "id > 0 OR id IN (:allowed_seeded_llm_ids)", - allowed_seeded_llm_ids: allowed_seeded_llm_ids, - ) - else - builder.where("id > 0") - end - - builder.query_hash.map(&:symbolize_keys) - end - - def self.values(allowed_seeded_llms: nil) - values = DB.query_hash(<<~SQL).map(&:symbolize_keys) - SELECT display_name AS name, id AS value - FROM llm_models - SQL - - if allowed_seeded_llms.is_a?(Array) - values = - values.filter do |value_h| - value_h[:value] > 0 || allowed_seeded_llms.include?("#{value_h[:value]}") - end - end - - values.each { |value_h| value_h[:value] = "custom:#{value_h[:value]}" } - values - end - end - end -end diff --git a/lib/configuration/llm_validator.rb b/lib/configuration/llm_validator.rb deleted file mode 100644 index 36c3c63b..00000000 --- a/lib/configuration/llm_validator.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Configuration - class InvalidSeededModelError < StandardError - end - - class LlmValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(val) - if val == "" - @parent_module_name = modules_and_choose_llm_settings.invert[@opts[:name]] - - @parent_enabled = SiteSetting.public_send(@parent_module_name) - return !@parent_enabled - end - - allowed_seeded_model?(val) - - run_test(val).tap { |result| @unreachable = result } - rescue DiscourseAi::Configuration::InvalidSeededModelError => e - @unreachable = true - false - rescue StandardError => e - raise e if Rails.env.test? - @unreachable = true - true - end - - def run_test(val) - DiscourseAi::Completions::Llm - .proxy(val) - .generate("How much is 1 + 1?", user: nil, feature_name: "llm_validator") - .present? - end - - def modules_using(llm_model) - choose_llm_settings = modules_and_choose_llm_settings.values - - choose_llm_settings.select { |s| SiteSetting.public_send(s) == "custom:#{llm_model.id}" } - end - - def error_message - if @parent_enabled - return( - I18n.t( - "discourse_ai.llm.configuration.disable_module_first", - setting: @parent_module_name, - ) - ) - end - - if @invalid_seeded_model - return I18n.t("discourse_ai.llm.configuration.invalid_seeded_model") - end - - return unless @unreachable - - I18n.t("discourse_ai.llm.configuration.model_unreachable") - end - - def choose_llm_setting_for(module_enabler_setting) - modules_and_choose_llm_settings[module_enabler_setting] - end - - def modules_and_choose_llm_settings - { - ai_embeddings_semantic_search_enabled: :ai_embeddings_semantic_search_hyde_model, - ai_helper_enabled: :ai_helper_model, - ai_summarization_enabled: :ai_summarization_model, - ai_translation_enabled: :ai_translation_model, - } - end - - def allowed_seeded_model?(val) - id = val.split(":").last - return true if id.to_i > 0 - - setting = @opts[:name] - allowed_list = SiteSetting.public_send("#{setting}_allowed_seeded_models") - - if allowed_list.split("|").exclude?(id) - @invalid_seeded_model = true - raise DiscourseAi::Configuration::InvalidSeededModelError.new - end - end - end - end -end diff --git a/lib/configuration/llm_vision_enumerator.rb b/lib/configuration/llm_vision_enumerator.rb deleted file mode 100644 index c4cf1a62..00000000 --- a/lib/configuration/llm_vision_enumerator.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require "enum_site_setting" - -module DiscourseAi - module Configuration - class LlmVisionEnumerator < ::EnumSiteSetting - def self.valid_value?(val) - true - end - - def self.values - values = DB.query_hash(<<~SQL).map(&:symbolize_keys) - SELECT display_name AS name, id AS value - FROM llm_models - WHERE vision_enabled - SQL - - values.each { |value_h| value_h[:value] = "custom:#{value_h[:value]}" } - - values - end - end - end -end diff --git a/lib/configuration/module.rb b/lib/configuration/module.rb deleted file mode 100644 index 2d3a26b6..00000000 --- a/lib/configuration/module.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Configuration - class Module - SUMMARIZATION = "summarization" - SEARCH = "search" - DISCORD = "discord" - INFERENCE = "inference" - AI_HELPER = "ai_helper" - TRANSLATION = "translation" - BOT = "bot" - SPAM = "spam" - EMBEDDINGS = "embeddings" - - NAMES = [ - SUMMARIZATION, - SEARCH, - DISCORD, - INFERENCE, - AI_HELPER, - TRANSLATION, - BOT, - SPAM, - EMBEDDINGS, - ].freeze - - SUMMARIZATION_ID = 1 - SEARCH_ID = 2 - DISCORD_ID = 3 - INFERENCE_ID = 4 - AI_HELPER_ID = 5 - TRANSLATION_ID = 6 - BOT_ID = 7 - SPAM_ID = 8 - EMBEDDINGS_ID = 9 - - class << self - def all - [ - new( - SUMMARIZATION_ID, - SUMMARIZATION, - enabled_by_setting: "ai_summarization_enabled", - features: DiscourseAi::Configuration::Feature.summarization_features, - ), - new( - SEARCH_ID, - SEARCH, - enabled_by_setting: "ai_bot_enabled", - features: DiscourseAi::Configuration::Feature.search_features, - extra_check: -> { SiteSetting.ai_bot_discover_persona.present? }, - ), - new( - DISCORD_ID, - DISCORD, - enabled_by_setting: "ai_discord_search_enabled", - features: DiscourseAi::Configuration::Feature.discord_features, - ), - new( - INFERENCE_ID, - INFERENCE, - enabled_by_setting: "inferred_concepts_enabled", - features: DiscourseAi::Configuration::Feature.inference_features, - ), - new( - AI_HELPER_ID, - AI_HELPER, - enabled_by_setting: "ai_helper_enabled", - features: DiscourseAi::Configuration::Feature.ai_helper_features, - ), - new( - TRANSLATION_ID, - TRANSLATION, - enabled_by_setting: "ai_translation_enabled", - features: DiscourseAi::Configuration::Feature.translation_features, - ), - new( - BOT_ID, - BOT, - enabled_by_setting: "ai_bot_enabled", - features: DiscourseAi::Configuration::Feature.bot_features, - ), - new( - SPAM_ID, - SPAM, - enabled_by_setting: "ai_spam_detection_enabled", - features: DiscourseAi::Configuration::Feature.spam_features, - ), - new( - EMBEDDINGS_ID, - EMBEDDINGS, - enabled_by_setting: "ai_embeddings_enabled", - features: DiscourseAi::Configuration::Feature.embeddings_features, - extra_check: -> { SiteSetting.ai_embeddings_semantic_search_enabled }, - ), - ] - end - - def find_by(id:) - all.find { |m| m.id == id } - end - end - - def initialize(id, name, enabled_by_setting: nil, features: [], extra_check: nil) - @id = id - @name = name - @enabled_by_setting = enabled_by_setting - @features = features - @extra_check = extra_check - end - - attr_reader :id, :name, :enabled_by_setting, :features - - def enabled? - return @extra_check.call if enabled_by_setting.blank? && @extra_check.present? - - enabled_setting = SiteSetting.get(enabled_by_setting) - - if @extra_check - enabled_setting && @extra_check.call - else - enabled_setting - end - end - end - end -end diff --git a/lib/configuration/persona_enumerator.rb b/lib/configuration/persona_enumerator.rb deleted file mode 100644 index c115bc50..00000000 --- a/lib/configuration/persona_enumerator.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require "enum_site_setting" - -module DiscourseAi - module Configuration - class PersonaEnumerator < ::EnumSiteSetting - def self.valid_value?(val) - true - end - - def self.values - AiPersona - .all_personas(enabled_only: false) - .map { |persona| { name: persona.name, value: persona.id } } - end - end - end -end diff --git a/lib/configuration/spam_detection_validator.rb b/lib/configuration/spam_detection_validator.rb deleted file mode 100644 index 6201cf3d..00000000 --- a/lib/configuration/spam_detection_validator.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Configuration - class SpamDetectionValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(val) - # only validate when enabling spam detection - return true if val == "f" || val == "false" - return true if AiModerationSetting.spam - - false - end - - def error_message - I18n.t("discourse_ai.spam_detection.configuration_missing") - end - end - end -end diff --git a/lib/database/connection.rb b/lib/database/connection.rb deleted file mode 100644 index f591e188..00000000 --- a/lib/database/connection.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Database - class Connection - def self.db - pg_conn = PG.connect(SiteSetting.ai_embeddings_pg_connection_string) - MiniSql::Connection.get(pg_conn) - end - end - end -end diff --git a/lib/discord/bot/base.rb b/lib/discord/bot/base.rb deleted file mode 100644 index abb87ac1..00000000 --- a/lib/discord/bot/base.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Discord::Bot - class Base - def initialize(body) - @interaction = JSON.parse(body, object_class: OpenStruct) - @query = @interaction.data.options.first.value - @token = @interaction.token - end - - def handle_interaction! - raise NotImplementedError - end - - def create_reply(reply) - api_endpoint = "https://discord.com/api/webhooks/#{SiteSetting.ai_discord_app_id}/#{@token}" - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = - conn.post( - api_endpoint, - { content: reply }.to_json, - { "Content-Type" => "application/json" }, - ) - @reply_response = JSON.parse(response.body, symbolize_names: true) - end - - def update_reply(reply) - api_endpoint = - "https://discord.com/api/webhooks/#{SiteSetting.ai_discord_app_id}/#{@token}/messages/@original" - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = - conn.patch( - api_endpoint, - { content: reply }.to_json, - { "Content-Type" => "application/json" }, - ) - @last_update_response = JSON.parse(response.body, symbolize_names: true) - end - end - end -end diff --git a/lib/discord/bot/persona_replier.rb b/lib/discord/bot/persona_replier.rb deleted file mode 100644 index 4ce9c2fe..00000000 --- a/lib/discord/bot/persona_replier.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Discord::Bot - class PersonaReplier < Base - def initialize(body) - @persona = - AiPersona - .all_personas(enabled_only: false) - .find { |p| p.id == SiteSetting.ai_discord_search_persona.to_i } - .new - @bot = - DiscourseAi::Personas::Bot.as( - Discourse.system_user, - persona: @persona, - model: LlmModel.find(@persona.class.default_llm_id), - ) - super(body) - end - - def handle_interaction! - last_update_sent_at = Time.now - 1 - reply = +"" - full_reply = - @bot.reply( - { conversation_context: [{ type: :user, content: @query }], skip_tool_details: true }, - ) do |partial, _something| - reply << partial - next if reply.blank? - - if @reply_response.nil? - create_reply(wrap_links(reply.dup)) - elsif @last_update_response.nil? - update_reply(wrap_links(reply.dup)) - elsif Time.now - last_update_sent_at > 1 - update_reply(wrap_links(reply.dup)) - last_update_sent_at = Time.now - end - end - - discord_reply = wrap_links(full_reply.last.first) - - if @reply_response.nil? - create_reply(discord_reply) - else - update_reply(discord_reply) - end - end - - def wrap_links(text) - text.gsub(%r{(?<url>https?://[^\s]+)}, "<\\k<url>>") - end - end - end -end diff --git a/lib/discord/bot/search.rb b/lib/discord/bot/search.rb deleted file mode 100644 index e33e35e7..00000000 --- a/lib/discord/bot/search.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Discord::Bot - class Search < Base - def initialize(body) - @search = DiscourseAi::Personas::Tools::Search - super(body) - end - - def handle_interaction! - results = - @search.new( - { search_query: @query }, - persona_options: { - "max_results" => 10, - }, - bot_user: nil, - llm: nil, - ).invoke(&Proc.new {}) - - formatted_results = results[:rows].map.with_index { |result, index| <<~RESULT }.join("\n") - #{index + 1}. [#{result[0]}](<#{Discourse.base_url}#{result[1]}>) - RESULT - - reply = <<~REPLY - Here are the top search results for your query: - - #{formatted_results} - REPLY - - create_reply(reply) - end - end - end -end diff --git a/lib/embeddings.rb b/lib/embeddings.rb deleted file mode 100644 index 6c066935..00000000 --- a/lib/embeddings.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Embeddings - def self.enabled? - SiteSetting.ai_embeddings_enabled && SiteSetting.ai_embeddings_selected_model.present? && - EmbeddingDefinition.exists?(id: SiteSetting.ai_embeddings_selected_model) - end - end -end diff --git a/lib/embeddings/entry_point.rb b/lib/embeddings/entry_point.rb deleted file mode 100644 index 67e2f3a3..00000000 --- a/lib/embeddings/entry_point.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Embeddings - class EntryPoint - def inject_into(plugin) - # Include random topics in the suggested list *only* if there are no related topics. - plugin.register_modifier( - :topic_view_suggested_topics_options, - ) do |suggested_options, topic_view| - related_topics = topic_view.related_topics - include_random = !related_topics || related_topics.topics.length == 0 - suggested_options.merge(include_random: include_random) - end - - # Query and serialize related topics. - plugin.add_to_class(:topic_view, :related_topics) do - if topic.private_message? || !SiteSetting.ai_embeddings_semantic_related_topics_enabled - return nil - end - - @related_topics ||= - ::DiscourseAi::Embeddings::SemanticTopicQuery.new(@user).list_semantic_related_topics( - topic, - ) - end - - # define_method must be used (instead of add_to_class) to make sure - # that method still works when plugin is disabled too - TopicView.alias_method(:categories_old, :categories) - TopicView.define_method(:categories) do - @categories ||= [*categories_old, *related_topics&.categories].flatten.uniq.compact - end - - %i[topic_view TopicViewPosts].each do |serializer| - plugin.add_to_serializer( - serializer, - :related_topics, - include_condition: -> { SiteSetting.ai_embeddings_semantic_related_topics_enabled }, - ) do - if object.next_page.nil? && !object.topic.private_message? - object.related_topics.topics.map do |t| - SuggestedTopicSerializer.new(t, scope: scope, root: false) - end - end - end - end - - plugin.register_html_builder("server:topic-show-after-posts-crawler") do |controller| - ::DiscourseAi::Embeddings::SemanticRelated.related_topics_for_crawler(controller) - end - - # embeddings generation. - callback = - Proc.new do |target| - if DiscourseAi::Embeddings.enabled? && - (target.is_a?(Topic) || SiteSetting.ai_embeddings_per_post_enabled) - Jobs.enqueue( - :generate_embeddings, - target_id: target.id, - target_type: target.class.name, - ) - end - end - - plugin.on(:topic_created, &callback) - plugin.on(:topic_edited, &callback) - plugin.on(:post_created, &callback) - plugin.on(:post_edited, &callback) - - plugin.add_api_key_scope( - :discourse_ai, - { search: { actions: %w[discourse_ai/embeddings/embeddings#search] } }, - ) - end - end - end -end diff --git a/lib/embeddings/schema.rb b/lib/embeddings/schema.rb deleted file mode 100644 index a43dbbf4..00000000 --- a/lib/embeddings/schema.rb +++ /dev/null @@ -1,288 +0,0 @@ -# frozen_string_literal: true - -# We don't have AR objects for our embeddings, so this class -# acts as an intermediary between us and the DB. -# It lets us retrieve embeddings either symmetrically and asymmetrically, -# and also store them. - -module DiscourseAi - module Embeddings - class Schema - TOPICS_TABLE = "ai_topics_embeddings" - POSTS_TABLE = "ai_posts_embeddings" - RAG_DOCS_TABLE = "ai_document_fragments_embeddings" - - EMBEDDING_TARGETS = %w[topics posts document_fragments] - EMBEDDING_TABLES = [TOPICS_TABLE, POSTS_TABLE, RAG_DOCS_TABLE] - - DEFAULT_HNSW_EF_SEARCH = 40 - - MissingEmbeddingError = Class.new(StandardError) - - class << self - def for(target_klass, vector_def: nil) - vector_def = - EmbeddingDefinition.find_by( - id: SiteSetting.ai_embeddings_selected_model, - ) if vector_def.nil? - raise "Invalid embeddings selected model" if vector_def.nil? - - case target_klass&.name - when "Topic" - new(TOPICS_TABLE, "topic_id", vector_def) - when "Post" - new(POSTS_TABLE, "post_id", vector_def) - when "RagDocumentFragment" - new(RAG_DOCS_TABLE, "rag_document_fragment_id", vector_def) - else - raise ArgumentError, "Invalid target type for embeddings" - end - end - - def search_index_name(table, def_id) - "ai_#{table}_embeddings_#{def_id}_1_search_bit" - end - - def prepare_search_indexes(vector_def) - EMBEDDING_TARGETS.each { |target| DB.exec <<~SQL } - CREATE INDEX IF NOT EXISTS #{search_index_name(target, vector_def.id)} ON ai_#{target}_embeddings - USING hnsw ((binary_quantize(embeddings)::bit(#{vector_def.dimensions})) bit_hamming_ops) - WHERE model_id = #{vector_def.id} AND strategy_id = 1; - SQL - end - - def correctly_indexed?(vector_def) - index_names = EMBEDDING_TARGETS.map { |t| search_index_name(t, vector_def.id) } - indexdefs = - DB.query_single( - "SELECT indexdef FROM pg_indexes WHERE indexname IN (:names)", - names: index_names, - ) - - return false if indexdefs.length < index_names.length - - indexdefs.all? do |defs| - defs.include? "(binary_quantize(embeddings))::bit(#{vector_def.dimensions})" - end - end - - def remove_orphaned_data - removed_defs_ids = - DB.query_single( - "SELECT DISTINCT(model_id) FROM #{TOPICS_TABLE} te LEFT JOIN embedding_definitions ed ON te.model_id = ed.id WHERE ed.id IS NULL", - ) - - EMBEDDING_TABLES.each do |t| - DB.exec( - "DELETE FROM #{t} WHERE model_id IN (:removed_defs)", - removed_defs: removed_defs_ids, - ) - end - - drop_index_statement = - EMBEDDING_TARGETS - .reduce([]) do |memo, et| - removed_defs_ids.each do |rdi| - memo << "DROP INDEX IF EXISTS #{search_index_name(et, rdi)};" - end - - memo - end - .join("\n") - - DB.exec(drop_index_statement) - end - end - - def initialize(table, target_column, vector_def) - @table = table - @target_column = target_column - @vector_def = vector_def - end - - attr_reader :table, :target_column, :vector_def - - def find_by_embedding(embedding) - DB.query( - <<~SQL, - SELECT * - FROM #{table} - WHERE - model_id = :vid AND strategy_id = :vsid - ORDER BY - embeddings::halfvec(#{dimensions}) #{pg_function} '[:query_embedding]'::halfvec(#{dimensions}) - LIMIT 1 - SQL - query_embedding: embedding, - vid: vector_def.id, - vsid: vector_def.strategy_id, - ).first - end - - def find_by_target(target) - DB.query( - <<~SQL, - SELECT * - FROM #{table} - WHERE - model_id = :vid AND - strategy_id = :vsid AND - #{target_column} = :target_id - LIMIT 1 - SQL - target_id: target.id, - vid: vector_def.id, - vsid: vector_def.strategy_id, - ).first - end - - def asymmetric_similarity_search(embedding, limit:, offset:) - before_query = hnsw_search_workaround(limit) - - builder = DB.build(<<~SQL) - WITH candidates AS ( - SELECT - #{target_column}, - embeddings::halfvec(#{dimensions}) AS embeddings - FROM - #{table} - /*join*/ - /*where*/ - ORDER BY - binary_quantize(embeddings)::bit(#{dimensions}) <~> binary_quantize('[:query_embedding]'::halfvec(#{dimensions})) - LIMIT :candidates_limit - ) - SELECT - #{target_column}, - embeddings::halfvec(#{dimensions}) #{pg_function} '[:query_embedding]'::halfvec(#{dimensions}) AS distance - FROM - candidates - ORDER BY - embeddings::halfvec(#{dimensions}) #{pg_function} '[:query_embedding]'::halfvec(#{dimensions}) - LIMIT :limit - OFFSET :offset; - SQL - - builder.where( - "model_id = :model_id AND strategy_id = :strategy_id", - model_id: vector_def.id, - strategy_id: vector_def.strategy_id, - ) - - yield(builder) if block_given? - - if table == RAG_DOCS_TABLE - # A too low limit exacerbates the the recall loss of binary quantization - candidates_limit = [limit * 2, 100].max - else - candidates_limit = limit * 2 - end - - ActiveRecord::Base.transaction do - DB.exec(before_query) if before_query.present? - builder.query( - query_embedding: embedding, - candidates_limit: candidates_limit, - limit: limit, - offset: offset, - ) - end - rescue PG::Error => e - Rails.logger.error("Error #{e} querying embeddings for model #{vector_def.display_name}") - raise MissingEmbeddingError - end - - def symmetric_similarity_search(record) - limit = 200 - before_query = hnsw_search_workaround(limit) - - builder = DB.build(<<~SQL) - WITH le_target AS ( - SELECT - embeddings - FROM - #{table} - WHERE - model_id = :vid AND - strategy_id = :vsid AND - #{target_column} = :target_id - LIMIT 1 - ) - SELECT #{target_column} FROM ( - SELECT - #{target_column}, embeddings - FROM - #{table} - /*join*/ - /*where*/ - ORDER BY - binary_quantize(embeddings)::bit(#{dimensions}) <~> ( - SELECT - binary_quantize(embeddings)::bit(#{dimensions}) - FROM - le_target - LIMIT 1 - ) - LIMIT #{limit} - ) AS widenet - ORDER BY - embeddings::halfvec(#{dimensions}) #{pg_function} ( - SELECT - embeddings::halfvec(#{dimensions}) - FROM - le_target - LIMIT 1 - ) - LIMIT #{limit / 2}; - SQL - - builder.where("model_id = :vid AND strategy_id = :vsid") - - yield(builder) if block_given? - - ActiveRecord::Base.transaction do - DB.exec(before_query) if before_query.present? - builder.query(vid: vector_def.id, vsid: vector_def.strategy_id, target_id: record.id) - end - rescue PG::Error => e - Rails.logger.error("Error #{e} querying embeddings for model #{vector_def.display_name}") - raise MissingEmbeddingError - end - - def store(record, embedding, digest) - DB.exec( - <<~SQL, - INSERT INTO #{table} (#{target_column}, model_id, model_version, strategy_id, strategy_version, digest, embeddings, created_at, updated_at) - VALUES (:target_id, :model_id, :model_version, :strategy_id, :strategy_version, :digest, '[:embeddings]', :now, :now) - ON CONFLICT (model_id, strategy_id, #{target_column}) - DO UPDATE SET - model_version = :model_version, - strategy_version = :strategy_version, - digest = :digest, - embeddings = '[:embeddings]', - updated_at = :now - SQL - target_id: record.id, - model_id: vector_def.id, - model_version: vector_def.version, - strategy_id: vector_def.strategy_id, - strategy_version: vector_def.strategy_version, - digest: digest, - embeddings: embedding, - now: Time.zone.now, - ) - end - - private - - def hnsw_search_workaround(limit) - threshold = limit * 2 - - return "" if threshold < DEFAULT_HNSW_EF_SEARCH - "SET LOCAL hnsw.ef_search = #{threshold};" - end - - delegate :dimensions, :pg_function, to: :vector_def - end - end -end diff --git a/lib/embeddings/semantic_related.rb b/lib/embeddings/semantic_related.rb deleted file mode 100644 index 8c0376cc..00000000 --- a/lib/embeddings/semantic_related.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Embeddings - class SemanticRelated - CACHE_PREFIX = "semantic-suggested-topic-" - - def self.clear_cache_for(topic) - Discourse.cache.delete("semantic-suggested-topic-#{topic.id}") - Discourse.redis.del("build-semantic-suggested-topic-#{topic.id}") - end - - def related_topic_ids_for(topic) - return [] if SiteSetting.ai_embeddings_semantic_related_topics < 1 - return [] if !DiscourseAi::Embeddings.enabled? # fail-safe in case something end up in a broken state. - - cache_for = results_ttl(topic) - - Discourse - .cache - .fetch(semantic_suggested_key(topic.id), expires_in: cache_for) do - DiscourseAi::Embeddings::Schema - .for(Topic) - .symmetric_similarity_search(topic) - .map(&:topic_id) - .tap do |candidate_ids| - # Happens when the topic doesn't have any embeddings - # I'd rather not use Exceptions to control the flow, so this should be refactored soon - if candidate_ids.empty? || !candidate_ids.include?(topic.id) - raise ::DiscourseAi::Embeddings::Schema::MissingEmbeddingError, - "No embeddings found for topic #{topic.id}" - end - end - end - rescue ::DiscourseAi::Embeddings::Schema::MissingEmbeddingError - # avoid a flood of jobs when visiting topic - if Discourse.redis.set( - build_semantic_suggested_key(topic.id), - "queued", - ex: 15.minutes.to_i, - nx: true, - ) - Jobs.enqueue(:generate_embeddings, target_type: "Topic", target_id: topic.id) - end - [] - end - - def results_ttl(topic) - case topic.created_at - when 6.hour.ago..Time.now - 15.minutes - when 3.day.ago..6.hour.ago - 1.hour - when 15.days.ago..3.day.ago - 12.hours - else - 1.week - end - end - - def self.related_topics_for_crawler(controller) - return "" if !controller.instance_of? TopicsController - return "" if !SiteSetting.ai_embeddings_semantic_related_topics_enabled - return "" if SiteSetting.ai_embeddings_semantic_related_topics < 1 - - topic_view = controller.instance_variable_get(:@topic_view) - topic = topic_view&.topic - return "" if !topic - - related_topics = SemanticTopicQuery.new(nil).list_semantic_related_topics(topic).topics - - return "" if related_topics.empty? - - ApplicationController.render( - template: "list/related_topics", - layout: false, - assigns: { - list: related_topics, - topic: topic, - }, - ) - end - - def self.clear_cache! - Discourse - .cache - .keys("#{CACHE_PREFIX}*") - .each { |key| Discourse.cache.delete(key.split(":").last) } - end - - private - - def semantic_suggested_key(topic_id) - "#{CACHE_PREFIX}#{topic_id}" - end - - def build_semantic_suggested_key(topic_id) - "build-#{CACHE_PREFIX}#{topic_id}" - end - end - end -end diff --git a/lib/embeddings/semantic_search.rb b/lib/embeddings/semantic_search.rb deleted file mode 100644 index 726f203d..00000000 --- a/lib/embeddings/semantic_search.rb +++ /dev/null @@ -1,249 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Embeddings - class SemanticSearch - def self.clear_cache_for(query) - digest = OpenSSL::Digest::SHA1.hexdigest(query) - - hyde_key = - "semantic-search-#{digest}-#{SiteSetting.ai_embeddings_semantic_search_hyde_model}" - - Discourse.cache.delete(hyde_key) - Discourse.cache.delete("#{hyde_key}-#{SiteSetting.ai_embeddings_selected_model}") - Discourse.cache.delete("-#{SiteSetting.ai_embeddings_selected_model}") - end - - def initialize(guardian) - @guardian = guardian - end - - def cached_query?(query) - digest = OpenSSL::Digest::SHA1.hexdigest(query) - embedding_key = - build_embedding_key( - digest, - SiteSetting.ai_embeddings_semantic_search_hyde_model, - SiteSetting.ai_embeddings_selected_model, - ) - - Discourse.cache.read(embedding_key).present? - end - - def vector - @vector ||= DiscourseAi::Embeddings::Vector.instance - end - - def hyde_embedding(search_term) - digest = OpenSSL::Digest::SHA1.hexdigest(search_term) - hyde_key = build_hyde_key(digest, SiteSetting.ai_embeddings_semantic_search_hyde_model) - - embedding_key = - build_embedding_key( - digest, - SiteSetting.ai_embeddings_semantic_search_hyde_model, - SiteSetting.ai_embeddings_selected_model, - ) - - hypothetical_post = - Discourse - .cache - .fetch(hyde_key, expires_in: 1.week) { hypothetical_post_from(search_term) } - - Discourse - .cache - .fetch(embedding_key, expires_in: 1.week) { vector.vector_from(hypothetical_post) } - end - - def embedding(search_term) - digest = OpenSSL::Digest::SHA1.hexdigest(search_term) - embedding_key = build_embedding_key(digest, "", SiteSetting.ai_embeddings_selected_model) - - Discourse.cache.fetch(embedding_key, expires_in: 1.week) { vector.vector_from(search_term) } - end - - # this ensures the candidate topics are over selected - # that way we have a much better chance of finding topics - # if the user filtered the results or index is a bit out of date - OVER_SELECTION_FACTOR = 4 - - def search_for_topics(query, page = 1, hyde: true) - max_results_per_page = 100 - limit = [Search.per_filter, max_results_per_page].min + 1 - offset = (page - 1) * limit - search = Search.new(query, { guardian: guardian }) - search_term = search.term - - if search_term.blank? || search_term.length < SiteSetting.min_search_term_length - return Post.none - end - - search_embedding = nil - search_embedding = hyde_embedding(search_term) if hyde - search_embedding = embedding(search_term) if search_embedding.blank? - - over_selection_limit = limit * OVER_SELECTION_FACTOR - - schema = DiscourseAi::Embeddings::Schema.for(Topic) - - candidate_topic_ids = - schema.asymmetric_similarity_search( - search_embedding, - limit: over_selection_limit, - offset: offset, - ).map(&:topic_id) - - semantic_results = - ::Post - .where(post_type: ::Topic.visible_post_types(guardian.user)) - .public_posts - .where("topics.visible") - .where(topic_id: candidate_topic_ids, post_number: 1) - .order("array_position(ARRAY#{candidate_topic_ids}, posts.topic_id)") - .limit(limit) - - query_filter_results = search.apply_filters(semantic_results) - - guardian.filter_allowed_categories(query_filter_results) - end - - def quick_search(query) - max_semantic_results_per_page = 100 - search = Search.new(query, { guardian: guardian }) - search_term = search.term - - return [] if search_term.nil? || search_term.length < SiteSetting.min_search_term_length - - vector = DiscourseAi::Embeddings::Vector.instance - - digest = OpenSSL::Digest::SHA1.hexdigest(search_term) - - embedding_key = - build_embedding_key( - digest, - SiteSetting.ai_embeddings_semantic_search_hyde_model, - SiteSetting.ai_embeddings_selected_model, - ) - - search_term_embedding = - Discourse - .cache - .fetch(embedding_key, expires_in: 1.week) do - vector.vector_from(search_term, asymetric: true) - end - - candidate_post_ids = - DiscourseAi::Embeddings::Schema - .for(Post) - .asymmetric_similarity_search( - search_term_embedding, - limit: max_semantic_results_per_page, - offset: 0, - ) - .map(&:post_id) - - semantic_results = - ::Post - .where(post_type: ::Topic.visible_post_types(guardian.user)) - .public_posts - .where("topics.visible") - .where(id: candidate_post_ids) - .order("array_position(ARRAY#{candidate_post_ids}, posts.id)") - - filtered_results = search.apply_filters(semantic_results) - - rerank_posts_payload = - filtered_results - .map(&:cooked) - .map { Nokogiri::HTML5.fragment(_1).text } - .map { _1.truncate(2000, omission: "") } - - reranked_results = - DiscourseAi::Inference::HuggingFaceTextEmbeddings.rerank( - search_term, - rerank_posts_payload, - ) - - reordered_ids = reranked_results.map { _1[:index] }.map { filtered_results[_1].id }.take(5) - - reranked_semantic_results = - ::Post - .where(post_type: ::Topic.visible_post_types(guardian.user)) - .public_posts - .where("topics.visible") - .where(id: reordered_ids) - .order("array_position(ARRAY#{reordered_ids}, posts.id)") - - guardian.filter_allowed_categories(reranked_semantic_results) - end - - def hypothetical_post_from(search_term) - context = - DiscourseAi::Personas::BotContext.new( - user: @guardian.user, - skip_tool_details: true, - feature_name: "semantic_search_hyde", - messages: [{ type: :user, content: search_term }], - ) - - bot = build_bot(@guardian.user) - return nil if bot.nil? - - structured_output = nil - raw_response = +"" - hyde_schema_key = bot.persona.response_format&.first.to_h - - buffer_blk = - Proc.new do |partial, _, type| - if type == :structured_output - structured_output = partial - elsif type.blank? - # Assume response is a regular completion. - raw_response << partial - end - end - - bot.reply(context, &buffer_blk) - - structured_output&.read_buffered_property(hyde_schema_key["key"]&.to_sym) || raw_response - end - - # Priorities are: - # 1. Persona's default LLM - # 2. `ai_embeddings_semantic_search_hyde_model` setting. - def find_ai_hyde_model(persona_klass) - model_id = - persona_klass.default_llm_id || - SiteSetting.ai_embeddings_semantic_search_hyde_model&.split(":")&.last - - return if model_id.blank? - - LlmModel.find_by(id: model_id) - end - - private - - attr_reader :guardian - - def build_hyde_key(digest, hyde_model) - "semantic-search-#{digest}-#{hyde_model}" - end - - def build_embedding_key(digest, hyde_model, embedding_model) - "#{build_hyde_key(digest, hyde_model)}-#{embedding_model}" - end - - def build_bot(user) - persona_id = SiteSetting.ai_embeddings_semantic_search_hyde_persona - - persona_klass = AiPersona.find_by(id: persona_id)&.class_instance - return if persona_klass.nil? - - llm_model = find_ai_hyde_model(persona_klass) - return if llm_model.nil? - - DiscourseAi::Personas::Bot.as(user, persona: persona_klass.new, model: llm_model) - end - end - end -end diff --git a/lib/embeddings/semantic_topic_query.rb b/lib/embeddings/semantic_topic_query.rb deleted file mode 100644 index db223baa..00000000 --- a/lib/embeddings/semantic_topic_query.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Embeddings::SemanticTopicQuery < TopicQuery - def list_semantic_related_topics(topic) - query_opts = { - skip_ordering: true, - per_page: SiteSetting.ai_embeddings_semantic_related_topics, - unordered: true, - } - - if !SiteSetting.ai_embeddings_semantic_related_include_closed_topics - query_opts[:status] = "open" - end - - list = - create_list(:semantic_related, query_opts) do |topics| - candidate_ids = DiscourseAi::Embeddings::SemanticRelated.new.related_topic_ids_for(topic) - - list = topics.where.not(id: topic.id).where(id: candidate_ids) - - list = DiscoursePluginRegistry.apply_modifier(:semantic_related_topics_query, list) - - # array_position forces the order of the topics to be preserved - list = list.order("array_position(ARRAY#{candidate_ids}, topics.id)") - list = remove_muted(list, @user, query_opts) - end - end -end diff --git a/lib/embeddings/strategies/truncation.rb b/lib/embeddings/strategies/truncation.rb deleted file mode 100644 index f747c1be..00000000 --- a/lib/embeddings/strategies/truncation.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Embeddings - module Strategies - class Truncation - def id - 1 - end - - def version - 1 - end - - def prepare_target_text(target, vdef) - max_length = vdef.max_sequence_length - 2 - - prepared_text = - case target - when Topic - topic_truncation(target, vdef.tokenizer, max_length) - when Post - post_truncation(target, vdef.tokenizer, max_length) - when RagDocumentFragment - vdef.tokenizer.truncate( - target.fragment, - max_length, - strict: SiteSetting.ai_strict_token_counting, - ) - else - raise ArgumentError, "Invalid target type" - end - - return prepared_text if vdef.embed_prompt.blank? - - [vdef.embed_prompt, prepared_text].join(" ") - end - - def prepare_query_text(text, vdef, asymetric: false) - qtext = asymetric ? "#{vdef.search_prompt} #{text}" : text - max_length = vdef.max_sequence_length - 2 - - vdef.tokenizer.truncate(qtext, max_length, strict: SiteSetting.ai_strict_token_counting) - end - - private - - def topic_information(topic) - info = +"" - - if topic&.title.present? - info << topic.title - info << "\n\n" - end - if topic&.category&.name.present? - info << topic.category.name - info << "\n\n" - end - if SiteSetting.tagging_enabled && topic&.tags.present? - info << topic.tags.pluck(:name).join(", ") - info << "\n\n" - end - - info - end - - def topic_truncation(topic, tokenizer, max_length) - text = +topic_information(topic) - - if topic&.topic_embed&.embed_content_cache.present? - text << Nokogiri::HTML5.fragment(topic.topic_embed.embed_content_cache).text - text << "\n\n" - end - - topic.posts.find_each do |post| - text << Nokogiri::HTML5.fragment(post.cooked).text - break if tokenizer.size(text) >= max_length #maybe keep a partial counter to speed this up? - text << "\n\n" - end - - tokenizer.truncate(text, max_length, strict: SiteSetting.ai_strict_token_counting) - end - - def post_truncation(post, tokenizer, max_length) - text = +topic_information(post.topic) - - if post.is_first_post? && post.topic&.topic_embed&.embed_content_cache.present? - text << Nokogiri::HTML5.fragment(post.topic.topic_embed.embed_content_cache).text - else - text << Nokogiri::HTML5.fragment(post.cooked).text - end - - tokenizer.truncate(text, max_length, strict: SiteSetting.ai_strict_token_counting) - end - end - end - end -end diff --git a/lib/embeddings/vector.rb b/lib/embeddings/vector.rb deleted file mode 100644 index 91ff4c2e..00000000 --- a/lib/embeddings/vector.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Embeddings - class Vector - def self.instance - vector_def = EmbeddingDefinition.find_by(id: SiteSetting.ai_embeddings_selected_model) - raise "Invalid embeddings selected model" if vector_def.nil? - - new(vector_def) - end - - def initialize(vector_definition) - @vdef = vector_definition - end - - delegate :tokenizer, to: :vdef - - def gen_bulk_reprensentations(relation) - http_pool_size = 100 - pool = - Concurrent::CachedThreadPool.new( - min_threads: 0, - max_threads: http_pool_size, - idletime: 30, - ) - - schema = DiscourseAi::Embeddings::Schema.for(relation.first.class, vector_def: @vdef) - - embedding_gen = vdef.inference_client - promised_embeddings = - relation - .map do |record| - prepared_text = vdef.prepare_target_text(record) - next if prepared_text.blank? - - new_digest = OpenSSL::Digest::SHA1.hexdigest(prepared_text) - next if schema.find_by_target(record)&.digest == new_digest - - Concurrent::Promises - .fulfilled_future({ target: record, text: prepared_text, digest: new_digest }, pool) - .then_on(pool) do |w_prepared_text| - w_prepared_text.merge(embedding: embedding_gen.perform!(w_prepared_text[:text])) - end - .rescue { nil } # We log the error during #perform. Skip failed embeddings. - end - .compact - - Concurrent::Promises - .zip(*promised_embeddings) - .value! - .each { |e| schema.store(e[:target], e[:embedding], e[:digest]) if e.present? } - ensure - pool.shutdown - pool.wait_for_termination - end - - def generate_representation_from(target) - text = vdef.prepare_target_text(target) - return if text.blank? - - schema = DiscourseAi::Embeddings::Schema.for(target.class, vector_def: @vdef) - - new_digest = OpenSSL::Digest::SHA1.hexdigest(text) - return if schema.find_by_target(target)&.digest == new_digest - - embeddings = vdef.inference_client.perform!(text) - - schema.store(target, embeddings, new_digest) - end - - def vector_from(text, asymetric: false) - prepared_text = vdef.prepare_query_text(text, asymetric: asymetric) - return if prepared_text.blank? - - vdef.inference_client.perform!(prepared_text) - end - - attr_reader :vdef - end - end -end diff --git a/lib/engine.rb b/lib/engine.rb deleted file mode 100644 index 6c43e22e..00000000 --- a/lib/engine.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - class Engine < ::Rails::Engine - engine_name PLUGIN_NAME - isolate_namespace DiscourseAi - end -end diff --git a/lib/guardian_extensions.rb b/lib/guardian_extensions.rb deleted file mode 100644 index 45cb1b55..00000000 --- a/lib/guardian_extensions.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module GuardianExtensions - def can_see_summary?(target) - return false if !SiteSetting.ai_summarization_enabled - - if target.class == Topic && target.private_message? - allowed = - SiteSetting.ai_pm_summarization_allowed_groups_map.any? do |group_id| - user.group_ids.include?(group_id) - end - - return false if !allowed - end - - has_cached_summary = - AiSummary.exists?(target: target, summary_type: AiSummary.summary_types[:complete]) - return has_cached_summary if user.nil? - - has_cached_summary || can_request_summary? - end - - def can_see_gists? - return false if !SiteSetting.ai_summarization_enabled - return false if !SiteSetting.ai_summary_gists_enabled - - if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summary_gists_persona)).blank? - return false - end - persona_groups = ai_persona.allowed_group_ids.to_a - return true if persona_groups.include?(Group::AUTO_GROUPS[:everyone]) - return false if anonymous? - - persona_groups.any? { |group_id| user.group_ids.include?(group_id) } - end - - def can_request_summary? - return false if anonymous? - - user_group_ids = user.group_ids - if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank? - return false - end - - ai_persona.allowed_group_ids.to_a.any? { |group_id| user.group_ids.include?(group_id) } - end - - def can_debug_ai_bot_conversation?(target) - return false if anonymous? - - return false if !can_see?(target) - - if !SiteSetting.discourse_ai_enabled || !SiteSetting.ai_bot_enabled || - !SiteSetting.ai_bot_debugging_allowed_groups_map.any? - return false - end - - user.in_any_groups?(SiteSetting.ai_bot_debugging_allowed_groups_map) - end - - def can_share_ai_bot_conversation?(target) - return false if anonymous? - - if !SiteSetting.discourse_ai_enabled || !SiteSetting.ai_bot_enabled || - !SiteSetting.ai_bot_public_sharing_allowed_groups_map.any? - return false - end - - return false if !user.in_any_groups?(SiteSetting.ai_bot_public_sharing_allowed_groups_map) - - # In future we may add other valid targets for AI conversation sharing, - # for now we only support topics. - if target.is_a?(Topic) - return false if !target.private_message? - return false if target.topic_allowed_groups.exists? - allowed_user_ids = target.topic_allowed_users.pluck(:user_id) - - # not in PM - return false if !allowed_user_ids.include?(user.id) - - # other people in PM - return false if allowed_user_ids.any? { |id| id > 0 && id != user.id } - - # no bot in the PM - bot_ids = DiscourseAi::AiBot::EntryPoint.all_bot_ids - return false if allowed_user_ids.none? { |id| bot_ids.include?(id) } - - # other content in PM - return false if target.posts.where("user_id > 0 and user_id <> ?", user.id).exists? - else - return false - end - - true - end - - def can_destroy_shared_ai_bot_conversation?(conversation) - return false if anonymous? - - conversation.user_id == user.id || is_admin? - end - end -end diff --git a/lib/inference/cloudflare_workers_ai.rb b/lib/inference/cloudflare_workers_ai.rb deleted file mode 100644 index adb9eb78..00000000 --- a/lib/inference/cloudflare_workers_ai.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class CloudflareWorkersAi - def initialize(endpoint, api_token, referer = Discourse.base_url) - @endpoint = endpoint - @api_token = api_token - @referer = referer - end - - attr_reader :endpoint, :api_token, :referer - - def perform!(content) - headers = { - "Referer" => Discourse.base_url, - "Content-Type" => "application/json", - "Authorization" => "Bearer #{api_token}", - } - - payload = { text: [content] } - - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = conn.post(endpoint, payload.to_json, headers) - - case response.status - when 200 - JSON.parse(response.body, symbolize_names: true).dig(:result, :data).first - else - Rails.logger.warn( - "Cloudflare Workers AI Embeddings failed with status: #{response.status} body: #{response.body}", - ) - raise Net::HTTPBadResponse.new(response.body.to_s) - end - end - end - end -end diff --git a/lib/inference/discourse_reranker.rb b/lib/inference/discourse_reranker.rb deleted file mode 100644 index 35b09799..00000000 --- a/lib/inference/discourse_reranker.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class DiscourseReranker - def self.perform!(endpoint, model, content, candidates, api_key) - headers = { "Referer" => Discourse.base_url, "Content-Type" => "application/json" } - - headers["X-API-KEY"] = api_key if api_key.present? - - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = - conn.post( - endpoint, - { model: model, content: content, candidates: candidates }.to_json, - headers, - ) - - raise Net::HTTPBadResponse unless response.status == 200 - - JSON.parse(response.body, symbolize_names: true) - end - end - end -end diff --git a/lib/inference/gemini_embeddings.rb b/lib/inference/gemini_embeddings.rb deleted file mode 100644 index b95cf03d..00000000 --- a/lib/inference/gemini_embeddings.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class GeminiEmbeddings - def initialize(embedding_url, api_key, referer = Discourse.base_url) - @api_key = api_key - @embedding_url = embedding_url - @referer = referer - end - - attr_reader :embedding_url, :api_key, :referer - - def perform!(content) - headers = { "Referer" => referer, "Content-Type" => "application/json" } - url = "#{embedding_url}\?key\=#{api_key}" - body = { content: { parts: [{ text: content }] } } - - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = conn.post(url, body.to_json, headers) - - case response.status - when 200 - JSON.parse(response.body, symbolize_names: true).dig(:embedding, :values) - else - Rails.logger.warn( - "Google Gemini Embeddings failed with status: #{response.status} body: #{response.body}", - ) - raise Net::HTTPBadResponse.new(response.body.to_s) - end - end - end - end -end diff --git a/lib/inference/hugging_face_text_embeddings.rb b/lib/inference/hugging_face_text_embeddings.rb deleted file mode 100644 index 67a964f8..00000000 --- a/lib/inference/hugging_face_text_embeddings.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class HuggingFaceTextEmbeddings - def initialize(endpoint, key, referer = Discourse.base_url) - @endpoint = endpoint - @key = key - @referer = referer - end - - attr_reader :endpoint, :key, :referer - - class << self - def reranker_configured? - SiteSetting.ai_hugging_face_tei_reranker_endpoint.present? || - SiteSetting.ai_hugging_face_tei_reranker_endpoint_srv.present? - end - - def rerank(content, candidates) - headers = { "Referer" => Discourse.base_url, "Content-Type" => "application/json" } - body = { query: content, texts: candidates, truncate: true }.to_json - - if SiteSetting.ai_hugging_face_tei_reranker_endpoint_srv.present? - service = - DiscourseAi::Utils::DnsSrv.lookup( - SiteSetting.ai_hugging_face_tei_reranker_endpoint_srv, - ) - api_endpoint = "https://#{service.target}:#{service.port}" - else - api_endpoint = SiteSetting.ai_hugging_face_tei_reranker_endpoint - end - - if SiteSetting.ai_hugging_face_tei_reranker_api_key.present? - headers["X-API-KEY"] = SiteSetting.ai_hugging_face_tei_reranker_api_key - headers["Authorization"] = "Bearer #{SiteSetting.ai_hugging_face_tei_reranker_api_key}" - end - - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = conn.post("#{api_endpoint}/rerank", body, headers) - - if response.status != 200 - raise Net::HTTPBadResponse.new("Status: #{response.status}\n\n#{response.body}") - end - - JSON.parse(response.body, symbolize_names: true) - end - end - - def classify_by_sentiment!(content) - response = do_request!(content) - - JSON.parse(response.body, symbolize_names: true) - end - - def perform!(content) - response = do_request!(content) - - JSON.parse(response.body, symbolize_names: true).first - end - - private - - def do_request!(content) - headers = { "Referer" => referer, "Content-Type" => "application/json" } - body = { inputs: content, truncate: true }.to_json - - if key.present? - headers["X-API-KEY"] = key - headers["Authorization"] = "Bearer #{key}" - end - - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = conn.post(endpoint, body, headers) - - raise Net::HTTPBadResponse.new(response.body.to_s) if ![200].include?(response.status) - - response - end - end - end -end diff --git a/lib/inference/open_ai_embeddings.rb b/lib/inference/open_ai_embeddings.rb deleted file mode 100644 index ec1845d9..00000000 --- a/lib/inference/open_ai_embeddings.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class OpenAiEmbeddings - def initialize(endpoint, api_key, model, dimensions) - @endpoint = endpoint - @api_key = api_key - @model = model - @dimensions = dimensions - end - - attr_reader :endpoint, :api_key, :model, :dimensions - - def perform!(content) - headers = { "Content-Type" => "application/json" } - - if endpoint.include?("azure") - headers["api-key"] = api_key - else - headers["Authorization"] = "Bearer #{api_key}" - end - - payload = { model: model, input: content } - payload[:dimensions] = dimensions if dimensions.present? - - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = conn.post(endpoint, payload.to_json, headers) - - case response.status - when 200 - JSON.parse(response.body, symbolize_names: true).dig(:data, 0, :embedding) - else - Rails.logger.warn( - "OpenAI Embeddings failed with status: #{response.status} body: #{response.body}", - ) - raise Net::HTTPBadResponse.new(response.body.to_s) - end - end - end - end -end diff --git a/lib/inference/open_ai_image_generator.rb b/lib/inference/open_ai_image_generator.rb deleted file mode 100644 index 83501989..00000000 --- a/lib/inference/open_ai_image_generator.rb +++ /dev/null @@ -1,469 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class OpenAiImageGenerator - TIMEOUT = 60 - MAX_IMAGE_SIZE = 20_971_520 # 20MB (technically 25 is supported by API) - - def self.create_uploads!( - prompts, - model:, - size: nil, - api_key: nil, - api_url: nil, - user_id:, - for_private_message: false, - n: 1, - quality: nil, - style: nil, - background: nil, - moderation: "low", - output_compression: nil, - output_format: nil, - title: nil, - cancel_manager: nil - ) - # Get the API responses in parallel threads - api_responses = - generate_images_in_threads( - prompts, - model: model, - size: size, - api_key: api_key, - api_url: api_url, - n: n, - quality: quality, - style: style, - background: background, - moderation: moderation, - output_compression: output_compression, - output_format: output_format, - cancel_manager: cancel_manager, - ) - - raise api_responses[0] if api_responses.all? { |resp| resp.is_a?(StandardError) } - - api_responses = api_responses.filter { |response| !response.is_a?(StandardError) } - - create_uploads_from_responses(api_responses, user_id, for_private_message, title) - end - - # Method for image editing that returns Upload objects - def self.create_edited_upload!( - images, - prompt, - model: "gpt-image-1", - size: "auto", - api_key: nil, - api_url: nil, - user_id:, - for_private_message: false, - n: 1, - quality: nil, - cancel_manager: nil - ) - api_response = - edit_images( - images, - prompt, - model: model, - size: size, - api_key: api_key, - api_url: api_url, - n: n, - quality: quality, - cancel_manager: cancel_manager, - ) - - create_uploads_from_responses([api_response], user_id, for_private_message).first - end - - # Common method to create uploads from API responses - def self.create_uploads_from_responses( - api_responses, - user_id, - for_private_message, - title = nil - ) - all_uploads = [] - - api_responses.each do |response| - next unless response - - response[:data].each_with_index do |image, index| - Tempfile.create("ai_image_#{index}.png") do |file| - file.binmode - file.write(Base64.decode64(image[:b64_json])) - file.rewind - - upload = - UploadCreator.new( - file, - title || "image.png", - for_private_message: for_private_message, - ).create_for(user_id) - - all_uploads << { - # Use revised_prompt if available (DALL-E 3), otherwise use original prompt - prompt: image[:revised_prompt] || response[:original_prompt], - upload: upload, - } - end - end - end - - all_uploads - end - - def self.generate_images_in_threads( - prompts, - model:, - size:, - api_key:, - api_url:, - n:, - quality:, - style:, - background:, - moderation:, - output_compression:, - output_format:, - cancel_manager: - ) - prompts = [prompts] unless prompts.is_a?(Array) - prompts = prompts.take(4) # Limit to 4 prompts max - - # Use provided values or defaults - api_key ||= SiteSetting.ai_openai_api_key - api_url ||= SiteSetting.ai_openai_image_generation_url - - # Thread processing - threads = [] - prompts.each do |prompt| - threads << Thread.new(prompt) do |inner_prompt| - attempts = 0 - begin - perform_generation_api_call!( - inner_prompt, - model: model, - size: size, - api_key: api_key, - api_url: api_url, - n: n, - quality: quality, - style: style, - background: background, - moderation: moderation, - output_compression: output_compression, - output_format: output_format, - cancel_manager: cancel_manager, - ) - rescue => e - attempts += 1 - # to keep tests speedy - if !Rails.env.test? && !cancel_manager&.cancelled? - retry if attempts < 3 - end - if !cancel_manager&.cancelled? - Discourse.warn_exception( - e, - message: "Failed to generate image for prompt #{prompt}\n", - ) - puts "Error generating image for prompt: #{prompt} #{e}" if Rails.env.development? - end - e - end - end - end - - threads.each(&:join) - threads.filter_map(&:value) - end - - def self.edit_images( - images, - prompt, - model: "gpt-image-1", - size: "auto", - api_key: nil, - api_url: nil, - n: 1, - quality: nil, - cancel_manager: nil - ) - images = [images] if !images.is_a?(Array) - - # For dall-e-2, only one image is supported - if model == "dall-e-2" && images.length > 1 - raise "DALL-E 2 only supports editing one image at a time" - end - - # For gpt-image-1, limit to 16 images - images = images.take(16) if model == "gpt-image-1" && images.length > 16 - - # Use provided values or defaults - api_key ||= SiteSetting.ai_openai_api_key - api_url ||= SiteSetting.ai_openai_image_edit_url - - # Execute edit API call - attempts = 0 - begin - perform_edit_api_call!( - images, - prompt, - model: model, - size: size, - api_key: api_key, - api_url: api_url, - n: n, - quality: quality, - cancel_manager: cancel_manager, - ) - rescue => e - raise e if cancel_manager&.cancelled? - attempts += 1 - if !Rails.env.test? - sleep 2 - retry if attempts < 3 - end - if Rails.env.development? - puts "Error editing image(s) with prompt: #{prompt} #{e}" - p e - end - Discourse.warn_exception(e, message: "Failed to edit image(s) with prompt #{prompt}") - raise e - end - end - - # Image generation API call method - def self.perform_generation_api_call!( - prompt, - model:, - size: nil, - api_key: nil, - api_url: nil, - n: 1, - quality: nil, - style: nil, - background: nil, - moderation: nil, - output_compression: nil, - output_format: nil, - cancel_manager: nil - ) - api_key ||= SiteSetting.ai_openai_api_key - api_url ||= SiteSetting.ai_openai_image_generation_url - - uri = URI(api_url) - headers = { "Content-Type" => "application/json" } - - if uri.host.include?("azure") - headers["api-key"] = api_key - else - headers["Authorization"] = "Bearer #{api_key}" - end - - # Build payload based on model type - payload = { model: model, prompt: prompt, n: n } - - # Add model-specific parameters - if model == "gpt-image-1" - if size - payload[:size] = size - else - payload[:size] = "auto" - end - payload[:background] = background if background - payload[:moderation] = moderation if moderation - payload[:output_compression] = output_compression if output_compression - payload[:output_format] = output_format if output_format - payload[:quality] = quality if quality - elsif model.start_with?("dall") - payload[:size] = size || "1024x1024" - payload[:quality] = quality || "hd" - payload[:style] = style if style - payload[:response_format] = "b64_json" - end - - # Store original prompt for upload metadata - original_prompt = prompt - cancel_manager_callback = nil - - FinalDestination::HTTP.start( - uri.host, - uri.port, - use_ssl: uri.scheme == "https", - read_timeout: TIMEOUT, - open_timeout: TIMEOUT, - write_timeout: TIMEOUT, - ) do |http| - request = Net::HTTP::Post.new(uri, headers) - request.body = payload.to_json - - if cancel_manager - cancel_manager_callback = lambda { http.finish } - cancel_manager.add_callback(cancel_manager_callback) - end - - json = nil - http.request(request) do |response| - if response.code.to_i != 200 - raise "OpenAI API returned #{response.code} #{response.body}" - else - json = JSON.parse(response.body, symbolize_names: true) - # Add original prompt to response to preserve it - json[:original_prompt] = original_prompt - end - end - json - end - ensure - if cancel_manager && cancel_manager_callback - cancel_manager.remove_callback(cancel_manager_callback) - end - end - - def self.perform_edit_api_call!( - images, - prompt, - model: "gpt-image-1", - size: "auto", - api_key:, - api_url:, - n: 1, - quality: nil, - cancel_manager: nil - ) - uri = URI(api_url) - - # Setup for multipart/form-data request - boundary = SecureRandom.hex - headers = { "Content-Type" => "multipart/form-data; boundary=#{boundary}" } - - if uri.host.include?("azure") - headers["api-key"] = api_key - else - headers["Authorization"] = "Bearer #{api_key}" - end - - # Create multipart form data - body = [] - - # Add model - body << "--#{boundary}\r\n" - body << "Content-Disposition: form-data; name=\"model\"\r\n\r\n" - - body << "#{model}\r\n" - - files_to_delete = [] - - # Add images - images.each do |image| - image_data = nil - image_filename = nil - - # Handle different image input types - if image.is_a?(Upload) - image_path = - if image.local? - Discourse.store.path_for(image) - else - filename = - Discourse.store.download_safe(image, max_file_size_kb: MAX_IMAGE_SIZE)&.path - files_to_delete << filename if filename - filename - end - image_data = File.read(image_path) - image_filename = File.basename(image.url) - else - raise "Unsupported image format. Must be an Upload" - end - - body << "--#{boundary}\r\n" - body << "Content-Disposition: form-data; name=\"image[]\"; filename=\"#{image_filename}\"\r\n" - body << "Content-Type: image/png\r\n\r\n" - body << image_data - body << "\r\n" - end - - # Add prompt - body << "--#{boundary}\r\n" - body << "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n" - body << "#{prompt}\r\n" - - # Add size if provided - if size - body << "--#{boundary}\r\n" - body << "Content-Disposition: form-data; name=\"size\"\r\n\r\n" - body << "#{size}\r\n" - end - - # Add n if provided and not the default - if n != 1 - body << "--#{boundary}\r\n" - body << "Content-Disposition: form-data; name=\"n\"\r\n\r\n" - body << "#{n}\r\n" - end - - # Add quality if provided - if quality - body << "--#{boundary}\r\n" - body << "Content-Disposition: form-data; name=\"quality\"\r\n\r\n" - body << "#{quality}\r\n" - end - - # Add response_format if provided - if model.start_with?("dall") - # Default to b64_json for consistency with generation - body << "--#{boundary}\r\n" - body << "Content-Disposition: form-data; name=\"response_format\"\r\n\r\n" - body << "b64_json\r\n" - end - - # End boundary - body << "--#{boundary}--\r\n" - - # Store original prompt for upload metadata - original_prompt = prompt - cancel_manager_callback = nil - - FinalDestination::HTTP.start( - uri.host, - uri.port, - use_ssl: uri.scheme == "https", - read_timeout: TIMEOUT, - open_timeout: TIMEOUT, - write_timeout: TIMEOUT, - ) do |http| - request = Net::HTTP::Post.new(uri.path, headers) - request.body = body.join - - if cancel_manager - cancel_manager_callback = lambda { http.finish } - cancel_manager.add_callback(cancel_manager_callback) - end - - json = nil - http.request(request) do |response| - if response.code.to_i != 200 - raise "OpenAI API returned #{response.code} #{response.body}" - else - json = JSON.parse(response.body, symbolize_names: true) - # Add original prompt to response to preserve it - json[:original_prompt] = original_prompt - end - end - json - end - ensure - if cancel_manager && cancel_manager_callback - cancel_manager.remove_callback(cancel_manager_callback) - end - if files_to_delete.present? - files_to_delete.each { |file| File.delete(file) if File.exist?(file) } - end - end - end - end -end diff --git a/lib/inference/stability_generator.rb b/lib/inference/stability_generator.rb deleted file mode 100644 index 124f5d8a..00000000 --- a/lib/inference/stability_generator.rb +++ /dev/null @@ -1,160 +0,0 @@ -# frozen_string_literal: true - -module ::DiscourseAi - module Inference - class StabilityGenerator - TIMEOUT = 120 - - # there is a new api for sd3 - def self.perform_sd3!( - prompt, - aspect_ratio: nil, - api_key: nil, - engine: nil, - api_url: nil, - output_format: "png", - seed: nil - ) - api_key ||= SiteSetting.ai_stability_api_key - engine ||= SiteSetting.ai_stability_engine - api_url ||= SiteSetting.ai_stability_api_url - - allowed_ratios = %w[16:9 1:1 21:9 2:3 3:2 4:5 5:4 9:16 9:21] - - aspect_ratio = "1:1" if !aspect_ratio || !allowed_ratios.include?(aspect_ratio) - - payload = { - prompt: prompt, - mode: "text-to-image", - model: engine, - output_format: output_format, - aspect_ratio: aspect_ratio, - } - - payload[:seed] = seed if seed - - endpoint = "v2beta/stable-image/generate/sd3" - - form_data = payload.to_a.map { |k, v| [k.to_s, v.to_s] } - - uri = URI("#{api_url}/#{endpoint}") - request = FinalDestination::HTTP::Post.new(uri) - - request["authorization"] = "Bearer #{api_key}" - request["accept"] = "application/json" - request["User-Agent"] = DiscourseAi::AiBot::USER_AGENT - request.set_form form_data, "multipart/form-data" - - response = - FinalDestination::HTTP.start( - uri.hostname, - uri.port, - use_ssl: uri.port != 80, - read_timeout: TIMEOUT, - open_timeout: TIMEOUT, - write_timeout: TIMEOUT, - ) { |http| http.request(request) } - - if response.code != "200" - Rails.logger.error( - "AI stability generator failed with status #{response.code}: #{response.body}}", - ) - raise Net::HTTPBadResponse - end - - parsed = JSON.parse(response.body, symbolize_names: true) - - # remap to old format - { artifacts: [{ base64: parsed[:image], seed: parsed[:seed] }] } - end - - def self.perform!( - prompt, - aspect_ratio: nil, - api_key: nil, - engine: nil, - api_url: nil, - image_count: 4, - seed: nil - ) - api_key ||= SiteSetting.ai_stability_api_key - engine ||= SiteSetting.ai_stability_engine - api_url ||= SiteSetting.ai_stability_api_url - - image_count = 4 if image_count > 4 - - if engine.start_with? "sd3" - artifacts = - image_count.times.map do - perform_sd3!( - prompt, - api_key: api_key, - engine: engine, - api_url: api_url, - aspect_ratio: aspect_ratio, - seed: seed, - )[ - :artifacts - ][ - 0 - ] - end - - return { artifacts: artifacts } - end - - headers = { - "Content-Type" => "application/json", - "Accept" => "application/json", - "Authorization" => "Bearer #{api_key}", - } - - ratio_to_dimension = { - "16:9" => [1536, 640], - "1:1" => [1024, 1024], - "21:9" => [1344, 768], - "2:3" => [896, 1152], - "3:2" => [1152, 896], - "4:5" => [832, 1216], - "5:4" => [1216, 832], - "9:16" => [640, 1536], - "9:21" => [768, 1344], - } - - if engine.include? "xl" - width, height = ratio_to_dimension[aspect_ratio] if aspect_ratio - - width, height = [1024, 1024] if !width || !height - else - width, height = [512, 512] - end - - payload = { - text_prompts: [{ text: prompt }], - cfg_scale: 7, - clip_guidance_preset: "FAST_BLUE", - height: width, - width: height, - samples: image_count, - steps: 30, - } - - payload[:seed] = seed if seed - - endpoint = "v1/generation/#{engine}/text-to-image" - - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - response = conn.post("#{api_url}/#{endpoint}", payload.to_json, headers) - - if response.status != 200 - Rails.logger.error( - "AI stability generator failed with status #{response.status}: #{response.body}}", - ) - raise Net::HTTPBadResponse - end - - JSON.parse(response.body, symbolize_names: true) - end - end - end -end diff --git a/lib/inferred_concepts/applier.rb b/lib/inferred_concepts/applier.rb deleted file mode 100644 index ca8ff58c..00000000 --- a/lib/inferred_concepts/applier.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module InferredConcepts - class Applier - # Associates the provided concepts with a topic - # topic: a Topic instance - # concepts: an array of InferredConcept instances - def apply_to_topic(topic, concepts) - return if topic.blank? || concepts.blank? - - topic.inferred_concepts << concepts - end - - # Associates the provided concepts with a post - # post: a Post instance - # concepts: an array of InferredConcept instances - def apply_to_post(post, concepts) - return if post.blank? || concepts.blank? - - post.inferred_concepts << concepts - end - - # Extracts content from a topic for concept analysis - # Returns a string with the topic title and first few posts - def topic_content_for_analysis(topic) - return "" if topic.blank? - - # Combine title and first few posts for analysis - posts = Post.where(topic_id: topic.id).order(:post_number).limit(10) - - content = "Title: #{topic.title}\n\n" - content += posts.map { |p| "#{p.post_number}) #{p.user.username}: #{p.raw}" }.join("\n\n") - - content - end - - # Extracts content from a post for concept analysis - # Returns a string with the post content - def post_content_for_analysis(post) - return "" if post.blank? - - # Get the topic title for context - topic_title = post.topic&.title || "" - - content = "Topic: #{topic_title}\n\n" - content += "Post by #{post.user.username}:\n#{post.raw}" - - content - end - - # Match a topic with existing concepts - def match_existing_concepts(topic) - return [] if topic.blank? - - # Get content to analyze - content = topic_content_for_analysis(topic) - - # Get all existing concepts - existing_concepts = DiscourseAi::InferredConcepts::Manager.new.list_concepts - return [] if existing_concepts.empty? - - # Use the ConceptMatcher persona to match concepts - matched_concept_names = match_concepts_to_content(content, existing_concepts) - - # Find concepts in the database - matched_concepts = InferredConcept.where(name: matched_concept_names) - - # Apply concepts to the topic - apply_to_topic(topic, matched_concepts) - - matched_concepts - end - - # Match a post with existing concepts - def match_existing_concepts_for_post(post) - return [] if post.blank? - - # Get content to analyze - content = post_content_for_analysis(post) - - # Get all existing concepts - existing_concepts = DiscourseAi::InferredConcepts::Manager.new.list_concepts - return [] if existing_concepts.empty? - - # Use the ConceptMatcher persona to match concepts - matched_concept_names = match_concepts_to_content(content, existing_concepts) - - # Find concepts in the database - matched_concepts = InferredConcept.where(name: matched_concept_names) - - # Apply concepts to the post - apply_to_post(post, matched_concepts) - - matched_concepts - end - - # Use ConceptMatcher persona to match content against provided concepts - def match_concepts_to_content(content, concept_list) - return [] if content.blank? || concept_list.blank? - - # Prepare user message with only the content - user_message = content - - # Use the ConceptMatcher persona to match concepts - - persona = - AiPersona - .all_personas(enabled_only: false) - .find { |p| p.id == SiteSetting.inferred_concepts_match_persona.to_i } - .new - - llm = LlmModel.find(persona.class.default_llm_id) - - input = { type: :user, content: content } - - context = - DiscourseAi::Personas::BotContext.new( - messages: [input], - user: Discourse.system_user, - inferred_concepts: concept_list, - ) - - bot = DiscourseAi::Personas::Bot.as(Discourse.system_user, persona: persona, model: llm) - structured_output = nil - - bot.reply(context) do |partial, _, type| - structured_output = partial if type == :structured_output - end - - structured_output&.read_buffered_property(:matching_concepts) || [] - end - end - end -end diff --git a/lib/inferred_concepts/finder.rb b/lib/inferred_concepts/finder.rb deleted file mode 100644 index 9e1466f5..00000000 --- a/lib/inferred_concepts/finder.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module InferredConcepts - class Finder - # Identifies potential concepts from provided content - # Returns an array of concept names (strings) - def identify_concepts(content) - return [] if content.blank? - - # Use the ConceptFinder persona to identify concepts - persona = - AiPersona - .all_personas(enabled_only: false) - .find { |p| p.id == SiteSetting.inferred_concepts_generate_persona.to_i } - .new - - llm = LlmModel.find(persona.class.default_llm_id) - context = - DiscourseAi::Personas::BotContext.new( - messages: [{ type: :user, content: content }], - user: Discourse.system_user, - inferred_concepts: DiscourseAi::InferredConcepts::Manager.new.list_concepts, - ) - - bot = DiscourseAi::Personas::Bot.as(Discourse.system_user, persona: persona, model: llm) - structured_output = nil - - bot.reply(context) do |partial, _, type| - structured_output = partial if type == :structured_output - end - - structured_output&.read_buffered_property(:concepts) || [] - end - - # Creates or finds concepts in the database from provided names - # Returns an array of InferredConcept instances - def create_or_find_concepts(concept_names) - return [] if concept_names.blank? - - concept_names.map { |name| InferredConcept.find_or_create_by(name: name) } - end - - # Finds candidate topics to use for concept generation - # - # @param limit [Integer] Maximum number of topics to return - # @param min_posts [Integer] Minimum number of posts in topic - # @param min_likes [Integer] Minimum number of likes across all posts - # @param min_views [Integer] Minimum number of views - # @param exclude_topic_ids [Array<Integer>] Topic IDs to exclude - # @param category_ids [Array<Integer>] Only include topics from these categories (optional) - # @param created_after [DateTime] Only include topics created after this time (optional) - # @return [Array<Topic>] Array of Topic objects that are good candidates - def find_candidate_topics( - limit: 100, - min_posts: 5, - min_likes: 10, - min_views: 100, - exclude_topic_ids: [], - category_ids: nil, - created_after: 30.days.ago - ) - query = - Topic.where( - "topics.posts_count >= ? AND topics.views >= ? AND topics.like_count >= ?", - min_posts, - min_views, - min_likes, - ) - - # Apply additional filters - query = query.where("topics.id NOT IN (?)", exclude_topic_ids) if exclude_topic_ids.present? - query = query.where("topics.category_id IN (?)", category_ids) if category_ids.present? - query = query.where("topics.created_at >= ?", created_after) if created_after.present? - - # Exclude PM topics (if they exist in Discourse) - query = query.where(archetype: Archetype.default) - - # Exclude topics that already have concepts - topics_with_concepts = <<~SQL - SELECT DISTINCT topic_id - FROM inferred_concept_topics - SQL - - query = query.where("topics.id NOT IN (#{topics_with_concepts})") - - # Score and order topics by engagement (combination of views, likes, and posts) - query = - query.select( - "topics.*, - (topics.like_count * 2 + topics.posts_count * 3 + topics.views * 0.1) AS engagement_score", - ).order("engagement_score DESC") - - # Return limited number of topics - query.limit(limit) - end - - # Find candidate posts that are good for concept generation - # - # @param limit [Integer] Maximum number of posts to return - # @param min_likes [Integer] Minimum number of likes - # @param exclude_first_posts [Boolean] Exclude first posts in topics - # @param exclude_post_ids [Array<Integer>] Post IDs to exclude - # @param category_ids [Array<Integer>] Only include posts from topics in these categories - # @param created_after [DateTime] Only include posts created after this time - # @return [Array<Post>] Array of Post objects that are good candidates - def find_candidate_posts( - limit: 100, - min_likes: 5, - exclude_first_posts: true, - exclude_post_ids: [], - category_ids: nil, - created_after: 30.days.ago - ) - query = Post.where("posts.like_count >= ?", min_likes) - - # Exclude first posts if specified - query = query.where("posts.post_number > 1") if exclude_first_posts - - # Apply additional filters - query = query.where("posts.id NOT IN (?)", exclude_post_ids) if exclude_post_ids.present? - query = query.where("posts.created_at >= ?", created_after) if created_after.present? - - # Filter by category if specified - if category_ids.present? - query = query.joins(:topic).where("topics.category_id IN (?)", category_ids) - end - - # Exclude posts that already have concepts - posts_with_concepts = <<~SQL - SELECT DISTINCT post_id - FROM inferred_concept_posts - SQL - - query = query.where("posts.id NOT IN (#{posts_with_concepts})") - - # Order by engagement (likes) - query = query.order(like_count: :desc) - - # Return limited number of posts - query.limit(limit) - end - - # Deduplicate and standardize a list of concepts - # @param concept_names [Array<String>] List of concept names to deduplicate - # @return [Hash] Hash with deduplicated concepts and mapping - def deduplicate_concepts(concept_names) - return { deduplicated_concepts: [], mapping: {} } if concept_names.blank? - - # Use the ConceptDeduplicator persona to deduplicate concepts - persona = - AiPersona - .all_personas(enabled_only: false) - .find { |p| p.id == SiteSetting.inferred_concepts_deduplicate_persona.to_i } - .new - - llm = LlmModel.find(persona.class.default_llm_id) - - # Create the input for the deduplicator - input = { type: :user, content: concept_names.join(", ") } - - context = - DiscourseAi::Personas::BotContext.new(messages: [input], user: Discourse.system_user) - - bot = DiscourseAi::Personas::Bot.as(Discourse.system_user, persona: persona, model: llm) - structured_output = nil - - bot.reply(context) do |partial, _, type| - structured_output = partial if type == :structured_output - end - - structured_output&.read_buffered_property(:streamlined_tags) || [] - end - end - end -end diff --git a/lib/inferred_concepts/manager.rb b/lib/inferred_concepts/manager.rb deleted file mode 100644 index 5ac96694..00000000 --- a/lib/inferred_concepts/manager.rb +++ /dev/null @@ -1,201 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module InferredConcepts - class Manager - # Get a list of existing concepts - # @param limit [Integer, nil] Optional maximum number of concepts to return - # @return [Array<InferredConcept>] Array of InferredConcept objects - def list_concepts(limit: nil) - query = InferredConcept.all.order("name ASC") - - # Apply limit if provided - query = query.limit(limit) if limit.present? - - query.pluck(:name) - end - - # Deduplicate concepts in batches by letter - # This method will: - # 1. Group concepts by first letter - # 2. Process each letter group separately through the deduplicator - # 3. Do a final pass with all deduplicated concepts - # @return [Hash] Statistics about the deduplication process - def deduplicate_concepts_by_letter(per_letter_batch: 50, full_pass_batch: 150) - # Get all concepts - all_concepts = list_concepts - return if all_concepts.empty? - - letter_groups = Hash.new { |h, k| h[k] = [] } - - # Group concepts by first letter - all_concepts.each do |concept| - first_char = concept[0]&.upcase - - if first_char && first_char.match?(/[A-Z]/) - letter_groups[first_char] << concept - else - # Non-alphabetic or empty concepts go in a special group - letter_groups["#"] << concept - end - end - - # Process each letter group - letter_deduplicated_concepts = [] - finder = DiscourseAi::InferredConcepts::Finder.new - - letter_groups.each do |letter, concepts| - next if concepts.empty? - - batches = concepts.each_slice(per_letter_batch).to_a - - batches.each do |batch| - result = finder.deduplicate_concepts(batch) - letter_deduplicated_concepts.concat(result) - end - end - - # Final pass with all deduplicated concepts - if letter_deduplicated_concepts.present? - final_result = [] - - batches = letter_deduplicated_concepts.each_slice(full_pass_batch).to_a - batches.each do |batch| - dedups = finder.deduplicate_concepts(batch) - final_result.concat(dedups) - end - - # Remove duplicates - final_result.uniq! - - # Apply the deduplicated concepts - InferredConcept.where.not(name: final_result).destroy_all - InferredConcept.insert_all(final_result.map { |concept| { name: concept } }) - end - end - - # Extract new concepts from arbitrary content - # @param content [String] The content to analyze - # @return [Array<String>] The identified concept names - def identify_concepts(content) - DiscourseAi::InferredConcepts::Finder.new.identify_concepts(content) - end - - # Identify and create concepts from content without applying them to any topic - # @param content [String] The content to analyze - # @return [Array<InferredConcept>] The created or found concepts - def generate_concepts_from_content(content) - return [] if content.blank? - - # Identify concepts - finder = DiscourseAi::InferredConcepts::Finder.new - concept_names = finder.identify_concepts(content) - return [] if concept_names.blank? - - # Create or find concepts in the database - finder.create_or_find_concepts(concept_names) - end - - # Generate concepts from a topic's content without applying them to the topic - # @param topic [Topic] A Topic instance - # @return [Array<InferredConcept>] The created or found concepts - def generate_concepts_from_topic(topic) - return [] if topic.blank? - - # Get content to analyze - applier = DiscourseAi::InferredConcepts::Applier.new - content = applier.topic_content_for_analysis(topic) - return [] if content.blank? - - # Generate concepts from the content - generate_concepts_from_content(content) - end - - # Generate concepts from a post's content without applying them to the post - # @param post [Post] A Post instance - # @return [Array<InferredConcept>] The created or found concepts - def generate_concepts_from_post(post) - return [] if post.blank? - - # Get content to analyze - applier = DiscourseAi::InferredConcepts::Applier.new - content = applier.post_content_for_analysis(post) - return [] if content.blank? - - # Generate concepts from the content - generate_concepts_from_content(content) - end - - # Match a topic against existing concepts - # @param topic [Topic] A Topic instance - # @return [Array<InferredConcept>] The concepts that were applied - def match_topic_to_concepts(topic) - return [] if topic.blank? - - DiscourseAi::InferredConcepts::Applier.new.match_existing_concepts(topic) - end - - # Match a post against existing concepts - # @param post [Post] A Post instance - # @return [Array<InferredConcept>] The concepts that were applied - def match_post_to_concepts(post) - return [] if post.blank? - - DiscourseAi::InferredConcepts::Applier.new.match_existing_concepts_for_post(post) - end - - # Find topics that have a specific concept - # @param concept_name [String] The name of the concept to search for - # @return [Array<Topic>] Topics that have the specified concept - def search_topics_by_concept(concept_name) - concept = ::InferredConcept.find_by(name: concept_name) - return [] unless concept - concept.topics - end - - # Find posts that have a specific concept - # @param concept_name [String] The name of the concept to search for - # @return [Array<Post>] Posts that have the specified concept - def search_posts_by_concept(concept_name) - concept = ::InferredConcept.find_by(name: concept_name) - return [] unless concept - concept.posts - end - - # Match arbitrary content against existing concepts - # @param content [String] The content to analyze - # @return [Array<String>] Names of matching concepts - def match_content_to_concepts(content) - existing_concepts = InferredConcept.all.pluck(:name) - return [] if existing_concepts.empty? - - DiscourseAi::InferredConcepts::Applier.new.match_concepts_to_content( - content, - existing_concepts, - ) - end - - # Find candidate topics that are good for concept generation - # - # @param opts [Hash] Options to pass to the finder - # @option opts [Integer] :limit (100) Maximum number of topics to return - # @option opts [Integer] :min_posts (5) Minimum number of posts in topic - # @option opts [Integer] :min_likes (10) Minimum number of likes across all posts - # @option opts [Integer] :min_views (100) Minimum number of views - # @option opts [Array<Integer>] :exclude_topic_ids ([]) Topic IDs to exclude - # @option opts [Array<Integer>] :category_ids (nil) Only include topics from these categories - # @option opts [DateTime] :created_after (30.days.ago) Only include topics created after this time - # @return [Array<Topic>] Array of Topic objects that are good candidates - def find_candidate_topics(opts = {}) - DiscourseAi::InferredConcepts::Finder.new.find_candidate_topics(**opts) - end - - # Find candidate posts that are good for concept generation - # @param opts [Hash] Options to pass to the finder - # @return [Array<Post>] Array of Post objects that are good candidates - def find_candidate_posts(opts = {}) - DiscourseAi::InferredConcepts::Finder.new.find_candidate_posts(**opts) - end - end - end -end diff --git a/lib/multisite_hash.rb b/lib/multisite_hash.rb deleted file mode 100644 index 16cf646a..00000000 --- a/lib/multisite_hash.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - class MultisiteHash - def initialize(id) - @hash = Hash.new { |h, k| h[k] = {} } - @id = id - - MessageBus.subscribe(channel_name) { |message| @hash[message.data] = {} } - end - - def channel_name - "/multisite-hash-#{@id}" - end - - def current_db - RailsMultisite::ConnectionManagement.current_db - end - - def fetch(key) - @hash[current_db][key] ||= yield - end - - def [](key) - @hash.dig(current_db, key) - end - - def []=(key, val) - @hash[current_db][key] = val - end - - def flush! - @hash[current_db] = {} - MessageBus.publish(channel_name, current_db) - end - - # TODO implement a GC so we don't retain too much memory - end -end diff --git a/lib/personas/artifact_update_strategies/base.rb b/lib/personas/artifact_update_strategies/base.rb deleted file mode 100644 index 1a1c0ecb..00000000 --- a/lib/personas/artifact_update_strategies/base.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module Personas - module ArtifactUpdateStrategies - class InvalidFormatError < StandardError - end - class Base - attr_reader :post, :user, :artifact, :artifact_version, :instructions, :llm, :cancel_manager - - def initialize( - llm:, - post:, - user:, - artifact:, - artifact_version:, - instructions:, - cancel_manager: nil - ) - @llm = llm - @post = post - @user = user - @artifact = artifact - @artifact_version = artifact_version - @instructions = instructions - @cancel_manager = cancel_manager - end - - def apply(&progress) - changes = generate_changes(&progress) - parsed_changes = parse_changes(changes) - apply_changes(parsed_changes) - end - - def storage_api - if @artifact.metadata.is_a?(Hash) && @artifact.metadata["requires_storage"] - DiscourseAi::Personas::Tools::CreateArtifact.storage_api - end - end - - private - - def generate_changes(&progress) - response = +"" - llm.generate(build_prompt, user: user, cancel_manager: cancel_manager) do |partial| - progress.call(partial) if progress - response << partial - end - response - end - - def build_prompt - # To be implemented by subclasses - raise NotImplementedError - end - - def parse_changes(response) - # To be implemented by subclasses - raise NotImplementedError - end - - def apply_changes(changes) - # To be implemented by subclasses - raise NotImplementedError - end - end - end - end -end diff --git a/lib/personas/artifact_update_strategies/diff.rb b/lib/personas/artifact_update_strategies/diff.rb deleted file mode 100644 index fb078691..00000000 --- a/lib/personas/artifact_update_strategies/diff.rb +++ /dev/null @@ -1,300 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module Personas - module ArtifactUpdateStrategies - class Diff < Base - attr_reader :failed_searches - - private - - def initialize(**kwargs) - super - @failed_searches = [] - end - - def build_prompt - DiscourseAi::Completions::Prompt.new( - system_prompt, - messages: [{ type: :user, content: user_prompt }], - post_id: post.id, - topic_id: post.topic_id, - ) - end - - def parse_changes(response) - sections = { html: nil, css: nil, javascript: nil } - current_section = nil - lines = [] - - response.each_line do |line| - case line - when /^\[(HTML|CSS|JavaScript)\]$/ - sections[current_section] = lines.join if current_section && !lines.empty? - current_section = line.match(/^\[(.+)\]$/)[1].downcase.to_sym - lines = [] - when %r{^\[/(?:HTML|CSS|JavaScript)\]$} - sections[current_section] = lines.join if current_section && !lines.empty? - current_section = nil - else - lines << line if current_section - end - end - - sections.each do |section, content| - sections[section] = extract_search_replace_blocks(content) - end - - sections - end - - def apply_changes(changes) - source = artifact_version || artifact - updated_content = { js: source.js, html: source.html, css: source.css } - - %i[html css javascript].each do |section| - blocks = changes[section] - next unless blocks - - content = source.public_send(section == :javascript ? :js : section) - blocks.each do |block| - begin - if !block[:search] - content = block[:replace] - else - content = - DiscourseAi::Utils::DiffUtils::SimpleDiff.apply( - content, - block[:search], - block[:replace], - ) - end - rescue DiscourseAi::Utils::DiffUtils::SimpleDiff::NoMatchError - @failed_searches << { section: section, search: block[:search] } - # TODO, we may need to inform caller here, LLM made a mistake which it - # should correct - puts "Failed to find search: #{block[:search]}" - end - end - updated_content[section == :javascript ? :js : section] = content - end - - artifact.create_new_version( - html: updated_content[:html], - css: updated_content[:css], - js: updated_content[:js], - change_description: instructions, - ) - end - - private - - def extract_search_replace_blocks(content) - return nil if content.blank? || content.to_s.strip.downcase.match?(/^\(?no changes?\)?$/m) - return [{ replace: content }] if !content.include?("<<< SEARCH") - - blocks = [] - current_block = {} - state = :initial - search_lines = [] - replace_lines = [] - - content.each_line do |line| - line = line.chomp - - case state - when :initial - state = :collecting_search if line.match?(/^<<<* SEARCH/) - when :collecting_search - if line.start_with?("===") - current_block[:search] = search_lines.join("\n").strip - search_lines = [] - state = :collecting_replace - else - search_lines << line - end - when :collecting_replace - if line.match?(/>>>* REPLACE/) - current_block[:replace] = replace_lines.join("\n").strip - replace_lines = [] - blocks << current_block - current_block = {} - state = :initial - else - replace_lines << line - end - end - end - - # Handle any remaining block - if state == :collecting_replace && !replace_lines.empty? - current_block[:replace] = replace_lines.join("\n").strip - blocks << current_block - end - - blocks.empty? ? nil : blocks - end - - def system_prompt - <<~PROMPT - You are a web development expert generating precise search/replace changes for updating HTML, CSS, and JavaScript code. - - CRITICAL RULES: - - 1. Use EXACTLY this format for changes: - <<<<<<< SEARCH - (code to replace) - ======= - (replacement code) - >>>>>>> REPLACE - - 2. SEARCH blocks MUST be 8 lines or less. Break larger changes into multiple smaller search/replace blocks. - - 3. DO NOT modify the markers or add spaces around them. - - 4. DO NOT add explanations or comments within sections. - - 5. ONLY include [HTML], [CSS], and [JavaScript] sections if they have changes. - - 6. HTML should not include <html>, <head>, or <body> tags, it is injected into a template. - - 7. NEVER EVER ask followup questions, ALL changes must be performed in a single response. - - 8. When performing a non-contiguous search, ALWAYS use ... to denote the skipped lines. - - 9. Be mindful that ... non-contiguous search is not greedy, it will only match the first occurrence. - - 10. Never mix a full section replacement with a search/replace block in the same section. - - 11. ALWAYS skip sections you do not want to change, do not include them in the response. - - HANDLING LARGE CHANGES: - - - Break large HTML structures into multiple smaller search/replace blocks. - - Use strategic anchor points like unique IDs or class names to target specific elements. - - Consider replacing entire components rather than modifying complex internals. - - When elements contain dynamic content, use precise context markers or replace entire containers. - - VALIDATION CHECKLIST: - - Each SEARCH block is 8 lines or less - - Every SEARCH has exactly one matching REPLACE - - All blocks are properly closed - - No SEARCH/REPLACE blocks are nested - - Each change is a complete, separate block with its own SEARCH/REPLACE markers - - WARNING: Never nest search/replace blocks. Each change must be a complete sequence. - - JavaScript libraries must be sourced from the following CDNs, otherwise CSP will reject it: - #{AiArtifact::ALLOWED_CDN_SOURCES.join("\n")} - - #{storage_api} - - Reply Format: - [HTML] - (changes or empty if no changes or entire HTML) - [/HTML] - [CSS] - (changes or empty if no changes or entire CSS) - [/CSS] - [JavaScript] - (changes or empty if no changes or entire JavaScript) - [/JavaScript] - - EXAMPLE 1 - Multiple small changes in one file: - - [JavaScript] - <<<<<<< SEARCH - console.log('old1'); - ======= - console.log('new1'); - >>>>>>> REPLACE - <<<<<<< SEARCH - console.log('old2'); - ======= - console.log('new2'); - >>>>>>> REPLACE - [/JavaScript] - - EXAMPLE 2 - Breaking up large HTML changes: - - [HTML] - <<<<<<< SEARCH - <div class="header"> - <div class="logo"> - <img src="old-logo.png"> - </div> - ======= - <div class="header"> - <div class="logo"> - <img src="new-logo.png"> - </div> - >>>>>>> REPLACE - - <<<<<<< SEARCH - <div class="navigation"> - <ul> - <li>Home</li> - <li>Products</li> - ======= - <div class="navigation"> - <ul> - <li>Home</li> - <li>Services</li> - >>>>>>> REPLACE - [/HTML] - - EXAMPLE 3 - Non-contiguous search in CSS: - - [CSS] - <<<<<<< SEARCH - body { - ... - background-color: green; - } - ======= - body { - color: red; - } - >>>>>>> REPLACE - [/CSS] - - EXAMPLE 4 - Full HTML replacement: - - [HTML] - <div>something old</div> - <div>another something old</div> - [/HTML] - - output: - - [HTML] - <div>something new</div> - [/HTML] - PROMPT - end - - def user_prompt - source = artifact_version || artifact - <<~CONTENT - Artifact code: - - [HTML] - #{source.html} - [/HTML] - - [CSS] - #{source.css} - [/CSS] - - [JavaScript] - #{source.js} - [/JavaScript] - - Instructions: - - #{instructions} - CONTENT - end - end - end - end -end diff --git a/lib/personas/artifact_update_strategies/full.rb b/lib/personas/artifact_update_strategies/full.rb deleted file mode 100644 index ffbf87e5..00000000 --- a/lib/personas/artifact_update_strategies/full.rb +++ /dev/null @@ -1,150 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module Personas - module ArtifactUpdateStrategies - class Full < Base - private - - def build_prompt - DiscourseAi::Completions::Prompt.new( - system_prompt, - messages: [ - { type: :user, content: "#{current_artifact_content}\n\n\n#{instructions}" }, - ], - post_id: post.id, - topic_id: post.topic_id, - ) - end - - def parse_changes(response) - sections = { html: nil, css: nil, javascript: nil } - current_section = nil - lines = [] - - response.each_line do |line| - case line - when /^\[(HTML|CSS|JavaScript)\]$/ - sections[current_section] = lines.join if current_section && !lines.empty? - current_section = line.match(/^\[(.+)\]$/)[1].downcase.to_sym - lines = [] - when %r{^\[/(HTML|CSS|JavaScript)\]$} - sections[current_section] = lines.join if current_section && !lines.empty? - current_section = nil - lines = [] - else - lines << line if current_section - end - end - - sections - end - - def apply_changes(changes) - source = artifact_version || artifact - updated_content = { js: source.js, html: source.html, css: source.css } - - %i[html css javascript].each do |section| - content = changes[section]&.strip - next if content.blank? - updated_content[section == :javascript ? :js : section] = content - end - - artifact.create_new_version( - html: updated_content[:html], - css: updated_content[:css], - js: updated_content[:js], - change_description: instructions, - ) - end - - private - - def system_prompt - <<~PROMPT - You are a web development expert generating updated HTML, CSS, and JavaScript code. - - Important rules: - 1. Provide full source code for each changed section - 2. Generate up to three sections: HTML, CSS, and JavaScript - 3. Only include sections that need changes - 4. Keep changes focused on the requirements - 5. NEVER EVER BE LAZY, always include ALL the source code with any update you make. If you are lazy you will break the artifact. - 6. Do not print out any reasoning, just the changed code, you will be parsed via a program. - 7. Sections must start and end with exact tags: [HTML] [/HTML], [CSS] [/CSS], [JavaScript] [/JavaScript] - 8. HTML should not include <html>, <head>, or <body> tags, it is injected into a template - - JavaScript libraries must be sourced from the following CDNs, otherwise CSP will reject it: - #{AiArtifact::ALLOWED_CDN_SOURCES.join("\n")} - - #{storage_api} - - Always adhere to the format when replying: - - [HTML] - complete html code, omit if no changes - [/HTML] - - [CSS] - complete css code, omit if no changes - [/CSS] - - [JavaScript] - complete js code, omit if no changes - [/JavaScript] - - Examples: - - Example 1 (HTML only change): - [HTML] - <div class="container"> - <h1>Title</h1> - </div> - [/HTML] - - Example 2 (CSS and JavaScript changes): - [CSS] - .container { padding: 20px; } - .title { color: blue; } - [/CSS] - [JavaScript] - function init() { - console.log("loaded"); - } - [/JavaScript] - - Example 3 (All sections): - [HTML] - <div id="app"></div> - [/HTML] - [CSS] - #app { margin: 0; } - [/CSS] - [JavaScript] - const app = document.getElementById("app"); - [/JavaScript] - - PROMPT - end - - def current_artifact_content - source = artifact_version || artifact - <<~CONTENT - Current artifact code: - - [HTML] - #{source.html} - [/HTML] - - [CSS] - #{source.css} - [/CSS] - - [JavaScript] - #{source.js} - [/JavaScript] - CONTENT - end - end - end - end -end diff --git a/lib/personas/artist.rb b/lib/personas/artist.rb deleted file mode 100644 index 93e2361e..00000000 --- a/lib/personas/artist.rb +++ /dev/null @@ -1,36 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class Artist < Persona - def tools - [Tools::Image] - end - - def required_tools - [Tools::Image] - end - - def system_prompt - <<~PROMPT - You are artistbot and you are here to help people generate images. - - You generate images using stable diffusion. - - - A good prompt needs to be detailed and specific. - - You can specify subject, medium (e.g. oil on canvas), artist (person who drew it or photographed it) - - You can specify details about lighting or time of day. - - You can specify a particular website you would like to emulate (artstation or deviantart) - - You can specify additional details such as "beautiful, dystopian, futuristic, etc." - - Prompts should generally be 10-20 words long - - Do not include any connector words such as "and" or "but" etc. - - You are extremely creative, when given short non descriptive prompts from a user you add your own details - - - When generating images, usually opt to generate 4 images unless the user specifies otherwise. - - Be creative with your prompts, offer diverse options - - You can use the seeds to regenerate the same image and amend the prompt keeping general style - PROMPT - end - end - end -end diff --git a/lib/personas/bot.rb b/lib/personas/bot.rb deleted file mode 100644 index 12bb1079..00000000 --- a/lib/personas/bot.rb +++ /dev/null @@ -1,346 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class Bot - BOT_NOT_FOUND = Class.new(StandardError) - - # the future is agentic, allow for more turns - MAX_COMPLETIONS = 8 - - # limit is arbitrary, but 5 which was used in the past was too low - MAX_TOOLS = 20 - - def self.as(bot_user, persona: DiscourseAi::Personas::General.new, model: nil) - new(bot_user, persona, model) - end - - def initialize(bot_user, persona, model = nil) - @bot_user = bot_user - @persona = persona - @model = - model || self.class.guess_model(bot_user) || LlmModel.find(@persona.class.default_llm_id) - end - - attr_reader :bot_user, :model - attr_accessor :persona - - def llm - DiscourseAi::Completions::Llm.proxy(model) - end - - def force_tool_if_needed(prompt, context) - return if prompt.tool_choice == :none - - context.chosen_tools ||= [] - forced_tools = persona.force_tool_use.map { |tool| tool.name } - force_tool = forced_tools.find { |name| !context.chosen_tools.include?(name) } - - if force_tool && persona.forced_tool_count > 0 - user_turns = prompt.messages.select { |m| m[:type] == :user }.length - force_tool = false if user_turns > persona.forced_tool_count - end - - if force_tool - context.chosen_tools << force_tool - prompt.tool_choice = force_tool - else - prompt.tool_choice = nil - end - end - - def reply(context, llm_args: {}, &update_blk) - unless context.is_a?(BotContext) - raise ArgumentError, "context must be an instance of BotContext" - end - context.cancel_manager ||= DiscourseAi::Completions::CancelManager.new - current_llm = llm - prompt = persona.craft_prompt(context, llm: current_llm) - - total_completions = 0 - ongoing_chain = true - raw_context = [] - - user = context.user - - llm_kwargs = llm_args.dup - llm_kwargs[:user] = user - llm_kwargs[:temperature] = persona.temperature if persona.temperature - llm_kwargs[:top_p] = persona.top_p if persona.top_p - - if !context.bypass_response_format && persona.response_format.present? - llm_kwargs[:response_format] = build_json_schema(persona.response_format) - end - - needs_newlines = false - tools_ran = 0 - - while total_completions < MAX_COMPLETIONS && ongoing_chain - tool_found = false - force_tool_if_needed(prompt, context) - - tool_halted = false - - allow_partial_tool_calls = persona.allow_partial_tool_calls? - existing_tools = Set.new - current_thinking = [] - - result = - current_llm.generate( - prompt, - feature_name: context.feature_name, - partial_tool_calls: allow_partial_tool_calls, - output_thinking: true, - cancel_manager: context.cancel_manager, - **llm_kwargs, - ) do |partial| - tool = - persona.find_tool( - partial, - bot_user: user, - llm: current_llm, - context: context, - existing_tools: existing_tools, - ) - tool = nil if tools_ran >= MAX_TOOLS - - if tool.present? - existing_tools << tool - tool_call = partial - if tool_call.partial? - if tool.class.allow_partial_tool_calls? - tool.partial_invoke - update_blk.call("", tool.custom_raw, :partial_tool) - end - next - end - - tool_found = true - # a bit hacky, but extra newlines do no harm - if needs_newlines - update_blk.call("\n\n") - needs_newlines = false - end - - process_tool( - tool: tool, - raw_context: raw_context, - current_llm: current_llm, - update_blk: update_blk, - prompt: prompt, - context: context, - current_thinking: current_thinking, - ) - - tools_ran += 1 - ongoing_chain &&= tool.chain_next_response? - - tool_halted = true if !tool.chain_next_response? - else - next if tool_halted - needs_newlines = true - if partial.is_a?(DiscourseAi::Completions::ToolCall) - Rails.logger.warn("DiscourseAi: Tool not found: #{partial.name}") - else - if partial.is_a?(DiscourseAi::Completions::Thinking) - if partial.partial? && partial.message.present? - update_blk.call(partial.message, nil, :thinking) - end - if !partial.partial? - # this will be dealt with later - raw_context << partial - current_thinking << partial - end - elsif update_blk.present? - if partial.is_a?(DiscourseAi::Completions::StructuredOutput) - update_blk.call(partial, nil, :structured_output) - else - update_blk.call(partial) - end - end - end - end - end - - if !tool_found - ongoing_chain = false - text = result - - # we must strip out thinking and other types of blocks - if result.is_a?(Array) - text = +"" - result.each { |item| text << item if item.is_a?(String) } - end - raw_context << [text, bot_user&.username] - end - - total_completions += 1 - - # do not allow tools when we are at the end of a chain (total_completions == MAX_COMPLETIONS - 1) - prompt.tool_choice = :none if total_completions == MAX_COMPLETIONS - 1 - end - - embed_thinking(raw_context) - end - - def returns_json? - persona.response_format.present? - end - - private - - def embed_thinking(raw_context) - embedded_thinking = [] - thinking_info = nil - raw_context.each do |context| - if context.is_a?(DiscourseAi::Completions::Thinking) - thinking_info ||= {} - if context.redacted - thinking_info[:redacted_thinking_signature] = context.signature - else - thinking_info[:thinking] = context.message - thinking_info[:thinking_signature] = context.signature - end - else - if thinking_info - context = context.dup - context[4] = thinking_info - end - embedded_thinking << context - end - end - - embedded_thinking - end - - def process_tool( - tool:, - raw_context:, - current_llm:, - update_blk:, - prompt:, - context:, - current_thinking: - ) - tool_call_id = tool.tool_call_id - invocation_result_json = invoke_tool(tool, context, &update_blk).to_json - - tool_call_message = { - type: :tool_call, - id: tool_call_id, - content: { arguments: tool.parameters }.to_json, - name: tool.name, - } - - if current_thinking.present? - current_thinking.each do |thinking| - if thinking.redacted - tool_call_message[:redacted_thinking_signature] = thinking.signature - else - tool_call_message[:thinking] = thinking.message - tool_call_message[:thinking_signature] = thinking.signature - end - end - end - - tool_message = { - type: :tool, - id: tool_call_id, - content: invocation_result_json, - name: tool.name, - } - - prompt.push(**tool_call_message) - prompt.push(**tool_message) - - raw_context << [tool_call_message[:content], tool_call_id, "tool_call", tool.name] - raw_context << [invocation_result_json, tool_call_id, "tool", tool.name] - end - - def invoke_tool(tool, context, &update_blk) - show_placeholder = !context.skip_tool_details && !tool.class.allow_partial_tool_calls? - - update_blk.call("", build_placeholder(tool.summary, "")) if show_placeholder - - result = - tool.invoke do |progress, render_raw| - if render_raw - update_blk.call("", tool.custom_raw, :partial_invoke) - show_placeholder = false - elsif show_placeholder - placeholder = build_placeholder(tool.summary, progress) - update_blk.call("", placeholder) - end - end - - if show_placeholder - tool_details = build_placeholder(tool.summary, tool.details, custom_raw: tool.custom_raw) - update_blk.call(tool_details, nil, :tool_details) - elsif tool.custom_raw.present? - update_blk.call(tool.custom_raw, nil, :custom_raw) - end - - result - end - - def self.guess_model(bot_user) - associated_llm = LlmModel.find_by(user_id: bot_user.id) - - return if associated_llm.nil? # Might be a persona user. Handled by constructor. - - associated_llm - end - - def build_placeholder(summary, details, custom_raw: nil) - placeholder = +(<<~HTML) - <details> - <summary>#{summary}</summary> - <p>#{details}</p> - </details> - HTML - - if custom_raw - placeholder << "\n" - placeholder << custom_raw - else - # we need this for cursor placeholder to work - # doing this in CSS is very hard - # if changing test with a custom tool such as search - placeholder << "<span></span>\n\n" - end - - placeholder - end - - def build_json_schema(response_format) - properties = - response_format - .to_a - .reduce({}) do |memo, format| - type_desc = { type: format["type"] } - - if format["type"] == "array" - type_desc[:items] = { type: format["array_type"] || "string" } - end - - memo[format["key"].to_sym] = type_desc - memo - end - - { - type: "json_schema", - json_schema: { - name: "reply", - schema: { - type: "object", - properties: properties, - required: properties.keys.map(&:to_s), - additionalProperties: false, - }, - strict: true, - }, - } - end - end - end -end diff --git a/lib/personas/bot_context.rb b/lib/personas/bot_context.rb deleted file mode 100644 index a42e7e96..00000000 --- a/lib/personas/bot_context.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class BotContext - attr_accessor :messages, - :topic_id, - :post_id, - :private_message, - :custom_instructions, - :user, - :skip_tool_details, - :participants, - :chosen_tools, - :message_id, - :channel_id, - :context_post_ids, - :feature_name, - :resource_url, - :cancel_manager, - :inferred_concepts, - :format_dates, - :temporal_context, - :user_language, - :bypass_response_format - - def initialize( - post: nil, - topic: nil, - participants: nil, - user: nil, - skip_tool_details: nil, - messages: [], - custom_instructions: nil, - site_url: nil, - site_title: nil, - site_description: nil, - time: nil, - message_id: nil, - channel_id: nil, - context_post_ids: nil, - feature_name: "bot", - resource_url: nil, - cancel_manager: nil, - inferred_concepts: [], - format_dates: false, - bypass_response_format: false - ) - @participants = participants - @user = user - @skip_tool_details = skip_tool_details - @messages = messages - @custom_instructions = custom_instructions - @format_dates = format_dates - - @message_id = message_id - @channel_id = channel_id - @context_post_ids = context_post_ids - - @site_url = site_url - @site_title = site_title - @site_description = site_description - @time = time - @resource_url = resource_url - - @feature_name = feature_name - @inferred_concepts = inferred_concepts - - @cancel_manager = cancel_manager - - @bypass_response_format = bypass_response_format - - if post - @post_id = post.id - @topic_id = post.topic_id - @private_message = post.topic.private_message? - @participants ||= post.topic.allowed_users.map(&:username).join(", ") if @private_message - @user ||= post.user - end - - if topic - @topic_id ||= topic.id - @private_message ||= topic.private_message? - @participants ||= topic.allowed_users.map(&:username).join(", ") if @private_message - @user ||= topic.user - end - end - - # these are strings that can be safely interpolated into templates - TEMPLATE_PARAMS = %w[ - time - site_url - site_title - site_description - participants - resource_url - inferred_concepts - user_language - temporal_context - top_categories - ] - - def lookup_template_param(key) - public_send(key.to_sym) if TEMPLATE_PARAMS.include?(key) - end - - def time - @time ||= Time.zone.now - end - - def site_url - @site_url ||= Discourse.base_url - end - - def site_title - @site_title ||= SiteSetting.title - end - - def site_description - @site_description ||= SiteSetting.site_description - end - - def private_message? - @private_message - end - - def top_categories - @top_categories ||= - Category - .where(read_restricted: false) - .order(posts_year: :desc) - .limit(10) - .pluck(:name) - .join(", ") - end - - def to_json - { - messages: @messages, - topic_id: @topic_id, - post_id: @post_id, - private_message: @private_message, - custom_instructions: @custom_instructions, - username: @user&.username, - user_id: @user&.id, - participants: @participants, - chosen_tools: @chosen_tools, - message_id: @message_id, - channel_id: @channel_id, - context_post_ids: @context_post_ids, - site_url: @site_url, - site_title: @site_title, - site_description: @site_description, - skip_tool_details: @skip_tool_details, - feature_name: @feature_name, - resource_url: @resource_url, - inferred_concepts: @inferred_concepts, - user_language: @user_language, - temporal_context: @temporal_context, - top_categories: @top_categories, - bypass_response_format: @bypass_response_format, - } - end - end - end -end diff --git a/lib/personas/concept_deduplicator.rb b/lib/personas/concept_deduplicator.rb deleted file mode 100644 index 3f2983c3..00000000 --- a/lib/personas/concept_deduplicator.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ConceptDeduplicator < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You will be given a list of machine-generated tags. - Your task is to streamline this list by merging entries who are similar or related. - - Please follow these steps to create a streamlined list of tags: - - 1. Review the entire list of tags carefully. - 2. Identify and remove any exact duplicates. - 3. Look for tags that are too specific or niche, and consider removing them or replacing them with more general terms. - 4. If there are multiple tags that convey similar concepts, choose the best one and remove the others, or add a new one that covers the missing aspect. - 5. Ensure that the remaining tags are relevant and useful for describing the content. - - When deciding which tags are "best", consider the following criteria: - - Relevance: How well does the tag describe the core content or theme? - - Generality: Is the tag specific enough to be useful, but not so specific that it's unlikely to be searched for? - - Clarity: Is the tag easy to understand and free from ambiguity? - - Popularity: Would this tag likely be used by people searching for this type of content? - - Example Input: - AI Bias, AI Bots, AI Ethics, AI Helper, AI Integration, AI Moderation, AI Search, AI-Driven Moderation, AI-Generated Post Illustrations, AJAX Events, AJAX Requests, AMA Events, API, API Access, API Authentication, API Automation, API Call, API Changes, API Compliance, API Configuration, API Costs, API Documentation, API Endpoint, API Endpoints, API Functions, API Integration, API Key, API Keys, API Limitation, API Limitations, API Permissions, API Rate Limiting, API Request, API Request Optimization, API Requests, API Security, API Suspension, API Token, API Tokens, API Translation, API Versioning, API configuration, API endpoint, API key, APIs, APK, APT Package Manager, ARIA, ARIA Tags, ARM Architecture, ARM-based, AWS, AWS Lightsail, AWS RDS, AWS S3, AWS Translate, AWS costs, AWS t2.micro, Abbreviation Expansion, Abbreviations - - Example Output: - AI, AJAX, API, APK, APT Package Manager, ARIA, ARM Architecture, AWS, Abbreviations - - Please provide your streamlined list of tags within <streamlined_tags> key. - - Remember, the goal is to create a more focused and effective set of tags while maintaining the essence of the original list. - - Your output should be in the following format: - <o> - { - "streamlined_tags": ["tag1", "tag3"] - } - </o> - PROMPT - end - - def response_format - [{ "key" => "streamlined_tags", "type" => "array", "array_type" => "string" }] - end - end - end -end diff --git a/lib/personas/concept_finder.rb b/lib/personas/concept_finder.rb deleted file mode 100644 index ab2da8f7..00000000 --- a/lib/personas/concept_finder.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ConceptFinder < Persona - def self.default_enabled - false - end - - def system_prompt - existing_concepts = DiscourseAi::InferredConcepts::Manager.new.list_concepts(limit: 100) - existing_concepts_text = "" - - existing_concepts_text = <<~CONCEPTS if existing_concepts.present? - The following concepts already exist in the system: - #{existing_concepts.join(", ")} - - You can reuse these existing concepts if they apply to the content, or suggest new concepts. - CONCEPTS - - <<~PROMPT.strip - You are an advanced concept tagging system that identifies key concepts, themes, and topics from provided text. - Your job is to extract meaningful labels that can be used to categorize content. - - Guidelines for generating concepts: - - Extract up to 7 concepts from the provided content - - Concepts should be single words or short phrases (1-3 words maximum) - - Focus on substantive topics, themes, technologies, methodologies, or domains - - Avoid overly general terms like "discussion" or "question" - - Ensure concepts are relevant to the core content - - Do not include proper nouns unless they represent key technologies or methodologies - - Maintain the original language of the text being analyzed - #{existing_concepts_text} - Format your response as a JSON object with a single key named "concepts", which has an array of concept strings as the value. - Your output should be in the following format: - <o> - {"concepts": ["concept1", "concept2", "concept3"]} - </o> - - Where the concepts are replaced by the actual concepts you've identified. - PROMPT - end - - def response_format - [{ "key" => "concepts", "type" => "array", "array_type" => "string" }] - end - end - end -end diff --git a/lib/personas/concept_matcher.rb b/lib/personas/concept_matcher.rb deleted file mode 100644 index 58f10c58..00000000 --- a/lib/personas/concept_matcher.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ConceptMatcher < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are an advanced concept matching system that determines which concepts from a provided list are relevant to a piece of content. - Your job is to analyze the content and determine which concepts from the list apply to it. - - Guidelines for matching concepts: - - Only select concepts that are clearly relevant to the content - - The content must substantially discuss or relate to the concept - - Superficial mentions are not enough to consider a concept relevant - - Be precise and selective - don't match concepts that are only tangentially related - - Consider both explicit mentions and implicit discussions of concepts - - Maintain the original language of the text being analyzed - - IMPORTANT: Only select from the exact concepts in the provided list - do not add new concepts - - If no concepts from the list match the content, return an empty array - - The list of available concepts is: - {inferred_concepts} - - Format your response as a JSON object with a single key named "matching_concepts", which has an array of concept strings from the provided list. - Your output should be in the following format: - <o> - {"matching_concepts": ["concept1", "concept3", "concept5"]} - </o> - - Only include concepts from the provided list that match the content. If no concepts match, return an empty array. - PROMPT - end - - def response_format - [{ "key" => "matching_concepts", "type" => "array", "array_type" => "string" }] - end - end - end -end diff --git a/lib/personas/content_creator.rb b/lib/personas/content_creator.rb deleted file mode 100644 index 3bbf3f87..00000000 --- a/lib/personas/content_creator.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ContentCreator < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are a content creator for a forum. The forum title and description is as follows: - * Ttitle: {site_title} - * Description: {site_description} - - You will receive a couple of keywords and must create a post about the keywords, keeping the previous information in mind. - - Format your response as a JSON object with a single key named "output", which has the created content. - Your output should be in the following format: - <output> - {"output": "xx"} - </output> - - Where "xx" is replaced by the content. - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - end - end -end diff --git a/lib/personas/creative.rb b/lib/personas/creative.rb deleted file mode 100644 index 407c2240..00000000 --- a/lib/personas/creative.rb +++ /dev/null @@ -1,17 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class Creative < Persona - def tools - [] - end - - def system_prompt - <<~PROMPT - You are a helpful bot - PROMPT - end - end - end -end diff --git a/lib/personas/custom_prompt.rb b/lib/personas/custom_prompt.rb deleted file mode 100644 index 16650830..00000000 --- a/lib/personas/custom_prompt.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class CustomPrompt < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are a helpful assistant. I will give you instructions inside <input></input> XML tags. - You will look at them and reply with a result. - - Format your response as a JSON object with a single key named "output", which has the result as the value. - Your output should be in the following format: - <output> - {"output": "xx"} - </output> - - Where "xx" is replaced by the result. - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - end - end -end diff --git a/lib/personas/dall_e_3.rb b/lib/personas/dall_e_3.rb deleted file mode 100644 index 851756c8..00000000 --- a/lib/personas/dall_e_3.rb +++ /dev/null @@ -1,37 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class DallE3 < Persona - def tools - [Tools::DallE] - end - - def required_tools - [Tools::DallE] - end - - def system_prompt - <<~PROMPT - As a DALL-E-3 bot, you're tasked with generating images based on user prompts. - - - Be specific and detailed in your prompts. Include elements like subject, medium (e.g., oil on canvas), artist style, lighting, time of day, and website style (e.g., ArtStation, DeviantArt). - - Add adjectives for more detail (e.g., beautiful, dystopian, futuristic). - - Prompts should be 40-100 words long, but remember the API accepts a maximum of 5000 characters per prompt. - - Enhance short, vague user prompts with your own creative details. - - Unless specified, generate 4 images per prompt. - - Don't seek user permission before generating images or run the prompts by the user. Generate immediately to save tokens. - - Example: - - User: "a cow" - You: Generate images immediately, without telling the user anything. Details will be provided to user with the generated images. - - DO NOT SAY "I will generate the following ... image 1 description ... image 2 description ... etc." - Just generate the images - - PROMPT - end - end - end -end diff --git a/lib/personas/designer.rb b/lib/personas/designer.rb deleted file mode 100644 index f2aa8dea..00000000 --- a/lib/personas/designer.rb +++ /dev/null @@ -1,28 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class Designer < Persona - def tools - [Tools::CreateImage, Tools::EditImage] - end - - def required_tools - [Tools::CreateImage, Tools::EditImage] - end - - def system_prompt - <<~PROMPT - You are a designer bot and you are here to help people generate and edit images. - - - A good prompt needs to be detailed and specific. - - You can specify subject, medium (e.g. oil on canvas), artist (person who drew it or photographed it) - - You can specify details about lighting or time of day. - - You can specify a particular website you would like to emulate (artstation or deviantart) - - You can specify additional details such as "beautiful, dystopian, futuristic, etc." - - Be extremely detailed with image prompts - PROMPT - end - end - end -end diff --git a/lib/personas/discourse_helper.rb b/lib/personas/discourse_helper.rb deleted file mode 100644 index 2e9db142..00000000 --- a/lib/personas/discourse_helper.rb +++ /dev/null @@ -1,46 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class DiscourseHelper < Persona - def tools - [Tools::DiscourseMetaSearch] - end - - def system_prompt - <<~PROMPT - You are Discourse Helper Bot - - - Discourse Helper Bot understands *markdown* and responds in Discourse **markdown**. - - Discourse Helper Bot has access to the search function on meta.discourse.org and can help answer user questions. - - Discourse Helper Bot ALWAYS backs up answers with actual search results from meta.discourse.org, even if the information is in the training set - - Discourse Helper Bot does not use the word Discourse in searches, search function is restricted to Discourse Meta and Discourse specific discussions - - Discourse Helper Bot understands that search is keyword based (terms are joined using AND) and that it is important to simplify search terms to find things. - - Discourse Helper Bot understands that users often badly phrase and misspell words, it will compensate for that by guessing what user means. - - Example: - - User asks: - - "I am on the discourse standad plan how do I enable badge sqls" - attempt #1: "badge sql standard" - attempt #2: "badge sql hosted" - - User asks: - - "how do i embed a discourse topic as an iframe" - attempt #1: "topic embed iframe" - attempt #2: "iframe" - - - Discourse Helper Bot ALWAYS SEARCHES TWICE, even if a great result shows up in the first search, it will search a second time using a wider net to make sure you are getting the best result. - - Some popular categories on meta are: bug, feature, support, ux, dev, documentation, announcements, marketplace, theme, plugin, theme-component, migration, installation. - - - Discourse Helper Bot will lean on categories to filter results. - - The date now is: {time}, much has changed since you were trained. - PROMPT - end - end - end -end diff --git a/lib/personas/forum_researcher.rb b/lib/personas/forum_researcher.rb deleted file mode 100644 index 3381b831..00000000 --- a/lib/personas/forum_researcher.rb +++ /dev/null @@ -1,58 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class ForumResearcher < Persona - def self.default_enabled - false - end - - def tools - [Tools::Researcher] - end - - def system_prompt - <<~PROMPT - You are a helpful Discourse assistant specializing in forum research. - You _understand_ and **generate** Discourse Markdown. - - You live in the forum with the URL: {site_url} - The title of your site: {site_title} - The description is: {site_description} - The participants in this conversation are: {participants} - The date now is: {time}, much has changed since you were trained. - Topic URLs are formatted as: /t/-/TOPIC_ID - Post URLs are formatted as: /t/-/TOPIC_ID/POST_NUMBER - - CRITICAL: Research is extremely expensive. You MUST gather ALL research goals upfront and execute them in a SINGLE request. Never run multiple research operations. - - As a forum researcher, follow this structured process: - 1. UNDERSTAND: Clarify ALL research goals - what insights are they seeking? - 2. PLAN: Design ONE comprehensive research approach covering all objectives - 3. TEST: Always begin with dry_run:true to gauge the scope of results - 4. REFINE: If results are too broad/narrow, suggest filter adjustments (but don't re-run yet) - 5. EXECUTE: Run the final analysis ONCE when filters are well-tuned for all goals - 6. SUMMARIZE: Present findings with links to supporting evidence - - Before any research, ask users to specify: - - ALL research questions they want answered - - Time periods of interest - - Specific users, categories, or tags to focus on - - Expected scope (broad overview vs. deep dive) - - Research filter guidelines: - - Use post date filters (after/before) for analyzing specific posts - - Use topic date filters (topic_after/topic_before) for analyzing entire topics - - Combine user/group filters with categories/tags to find specialized contributions - - When formatting results: - - Link to topics with descriptive text when relevant - - Use markdown footnotes for supporting evidence - - Always ground analysis with links to original forum posts - - Remember: ONE research request should answer ALL questions. Plan comprehensively before executing. - PROMPT - end - end - end -end diff --git a/lib/personas/general.rb b/lib/personas/general.rb deleted file mode 100644 index 01c05e08..00000000 --- a/lib/personas/general.rb +++ /dev/null @@ -1,32 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class General < Persona - def tools - [ - Tools::Search, - Tools::Google, - Tools::Image, - Tools::Read, - Tools::ListCategories, - Tools::ListTags, - ] - end - - def system_prompt - <<~PROMPT - You are a helpful Discourse assistant. - You _understand_ and **generate** Discourse Markdown. - You live in a Discourse Forum Message. - - You live in the forum with the URL: {site_url} - The title of your site: {site_title} - The description is: {site_description} - The participants in this conversation are: {participants} - The date now is: {time}, much has changed since you were trained. - PROMPT - end - end - end -end diff --git a/lib/personas/github_helper.rb b/lib/personas/github_helper.rb deleted file mode 100644 index bd75624d..00000000 --- a/lib/personas/github_helper.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class GithubHelper < Persona - def tools - [ - Tools::GithubFileContent, - Tools::GithubPullRequestDiff, - Tools::GithubSearchCode, - Tools::GithubSearchFiles, - ] - end - - def system_prompt - <<~PROMPT - You are a helpful GitHub assistant. - You _understand_ and **generate** Discourse Flavored Markdown. - You live in a Discourse Forum Message. - - Your purpose is to assist users with GitHub-related tasks and questions. - When asked about a specific repository, pull request, or file, try to use the available tools to provide accurate and helpful information. - - The date now is: {time}, much has changed since you were trained. - PROMPT - end - end - end -end diff --git a/lib/personas/image_captioner.rb b/lib/personas/image_captioner.rb deleted file mode 100644 index 7585857a..00000000 --- a/lib/personas/image_captioner.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ImageCaptioner < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are a bot specializing in image captioning. - - Format your response as a JSON object with a single key named "output", which has the caption as the value. - Your output should be in the following format: - <output> - {"output": "xx"} - </output> - - Where "xx" is replaced by the caption. - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - end - end -end diff --git a/lib/personas/locale_detector.rb b/lib/personas/locale_detector.rb deleted file mode 100644 index 4eb05a5a..00000000 --- a/lib/personas/locale_detector.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class LocaleDetector < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You will be given a piece of text, and your task is to detect the locale (language) of the text and return it in a specific JSON format. - - To complete this task, follow these steps: - - 1. Carefully read and analyze the provided text. - 2. Determine the language of the text based on its characteristics, such as vocabulary, grammar, and sentence structure. - 3. Do not use links or programming code in the text to detect the locale - 4. Identify the appropriate language code for the detected language. - - Here is a list of common language codes for reference: - - English: en - - Spanish: es - - French: fr - - German: de - - Italian: it - - Brazilian Portuguese: pt-BR - - Russian: ru - - Simplified Chinese: zh-CN - - Japanese: ja - - Korean: ko - - If the language is not in this list, use the appropriate IETF language tag code. - - 5. Avoid using `und` and prefer `en` over `en-US` or `en-GB` unless the text specifically indicates a regional variant. - - Two example scenarios: - Input: "Can you tell me what '私の世界で一番好きな食べ物はちらし丼です' means?" - Output: "en" - - Input: [quote]\nNon smettere mai di credere nella bellezza dei tuoi sogni. Anche quando tutto sembra perduto, c'è sempre una luce che aspetta di essere trovata.\nOgni passo, anche il più piccolo, ti avvicina a ciò che desideri. La forza che cerchi è già dentro di te.\n[/quote]\n¿Cuál es el mensaje principal de esta cita? - Output: "es" - - Important: Base your analysis solely on the provided text. Do not use any external information or make assumptions about the text's origin or context beyond what is explicitly provided. - - Your response must be a language code, and nothing else. - PROMPT - end - - def temperature - 0 - end - end - end -end diff --git a/lib/personas/markdown_table_generator.rb b/lib/personas/markdown_table_generator.rb deleted file mode 100644 index 581f0eb7..00000000 --- a/lib/personas/markdown_table_generator.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class MarkdownTableGenerator < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are a markdown table formatter, I will provide you text inside <input></input> XML tags and you will format it into a markdown table - - Format your response as a JSON object with a single key named "output", which has the formatted table as the value. - Your output should be in the following format: - <output> - {"output": "xx"} - </output> - - Where "xx" is replaced by the formatted table. - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - - def temperature - 0.5 - end - - def examples - [ - ["<input>sam,joe,jane\nage: 22| 10|11</input>", { output: <<~TEXT }.to_json], - | | sam | joe | jane | - |---|---|---|---| - | age | 22 | 10 | 11 | - TEXT - [<<~TEXT, { output: <<~TEXT }.to_json], - <input> - sam: speed 100, age 22 - jane: age 10 - fred: height 22 - </input> - TEXT - | | speed | age | height | - |---|---|---|---| - | sam | 100 | 22 | - | - | jane | - | 10 | - | - | fred | - | - | 22 | - TEXT - [<<~TEXT, { output: <<~TEXT }.to_json], - <input> - chrome 22ms (first load 10ms) - firefox 10ms (first load: 9ms) - </input> - TEXT - | Browser | Load Time (ms) | First Load Time (ms) | - |---|---|---| - | Chrome | 22 | 10 | - | Firefox | 10 | 9 | - TEXT - ] - end - end - end -end diff --git a/lib/personas/persona.rb b/lib/personas/persona.rb deleted file mode 100644 index 8773ad82..00000000 --- a/lib/personas/persona.rb +++ /dev/null @@ -1,456 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class Persona - class << self - def default_enabled - true - end - - def rag_conversation_chunks - 10 - end - - def vision_enabled - false - end - - def vision_max_pixels - 1_048_576 - end - - def question_consolidator_llm_id - nil - end - - def force_default_llm - false - end - - def allow_chat_channel_mentions - false - end - - def allow_chat_direct_messages - false - end - - def system_personas - @system_personas ||= { - General => -1, - SqlHelper => -2, - Artist => -3, - SettingsExplorer => -4, - Researcher => -5, - Creative => -6, - DallE3 => -7, - DiscourseHelper => -8, - GithubHelper => -9, - WebArtifactCreator => -10, - Summarizer => -11, - ShortSummarizer => -12, - Designer => -13, - ForumResearcher => -14, - ConceptFinder => -15, - ConceptMatcher => -16, - ConceptDeduplicator => -17, - CustomPrompt => -18, - SmartDates => -19, - MarkdownTableGenerator => -20, - PostIllustrator => -21, - Proofreader => -22, - TitlesGenerator => -23, - Tutor => -24, - Translator => -25, - ImageCaptioner => -26, - LocaleDetector => -27, - PostRawTranslator => -28, - TopicTitleTranslator => -29, - ShortTextTranslator => -30, - SpamDetector => -31, - ContentCreator => -32, - ReportRunner => -33, - } - end - - def system_personas_by_id - @system_personas_by_id ||= system_personas.invert - end - - def all(user:) - # listing tools has to be dynamic cause site settings may change - AiPersona.all_personas.filter do |persona| - next false if !user.in_any_groups?(persona.allowed_group_ids) - - if persona.system - instance = persona.new - ( - instance.required_tools == [] || - (instance.required_tools - all_available_tools).empty? - ) - else - true - end - end - end - - def find_by(id: nil, name: nil, user:) - all(user: user).find { |persona| persona.id == id || persona.name == name } - end - - def name - I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.name") - end - - def description - I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description") - end - - def all_available_tools - tools = [ - Tools::ListCategories, - Tools::Time, - Tools::Search, - Tools::Read, - Tools::DbSchema, - Tools::SearchSettings, - Tools::SettingContext, - Tools::RandomPicker, - Tools::DiscourseMetaSearch, - Tools::GithubFileContent, - Tools::GithubPullRequestDiff, - Tools::GithubSearchFiles, - Tools::WebBrowser, - Tools::JavascriptEvaluator, - Tools::Researcher, - ] - - if SiteSetting.ai_artifact_security.in?(%w[lax hybrid strict]) - tools << Tools::CreateArtifact - tools << Tools::UpdateArtifact - tools << Tools::ReadArtifact - end - - tools << Tools::GithubSearchCode if SiteSetting.ai_bot_github_access_token.present? - - tools << Tools::ListTags if SiteSetting.tagging_enabled - tools << Tools::Image if SiteSetting.ai_stability_api_key.present? - - if SiteSetting.ai_openai_api_key.present? - tools << Tools::DallE - tools << Tools::CreateImage - tools << Tools::EditImage - end - - if SiteSetting.ai_google_custom_search_api_key.present? && - SiteSetting.ai_google_custom_search_cx.present? - tools << Tools::Google - end - - tools - end - end - - def id - @ai_persona&.id || self.class.system_personas[self.class.superclass] || - self.class.system_personas[self.class] - end - - def tools - [] - end - - def force_tool_use - [] - end - - def forced_tool_count - -1 - end - - def required_tools - [] - end - - def temperature - nil - end - - def top_p - nil - end - - def options - {} - end - - def response_format - nil - end - - def examples - [] - end - - def available_tools - self - .class - .all_available_tools - .filter { |tool| tools.include?(tool) } - .concat(tools.filter(&:custom?)) - end - - def craft_prompt(context, llm: nil) - system_insts = replace_placeholders(system_prompt, context) - - prompt_insts = <<~TEXT.strip - #{system_insts} - #{available_tools.map(&:custom_system_message).compact_blank.join("\n")} - TEXT - - question_consolidator_llm = llm - if self.class.question_consolidator_llm_id.present? - question_consolidator_llm ||= - DiscourseAi::Completions::Llm.proxy( - LlmModel.find_by(id: self.class.question_consolidator_llm_id), - ) - end - - if context.custom_instructions.present? - prompt_insts << "\n" - prompt_insts << context.custom_instructions - end - - fragments_guidance = - rag_fragments_prompt( - context.messages, - llm: question_consolidator_llm, - user: context.user, - )&.strip - - prompt_insts << fragments_guidance if fragments_guidance.present? - - post_system_examples = [] - - if examples.present? - examples.flatten.each_with_index do |e, idx| - post_system_examples << { - content: replace_placeholders(e, context), - type: (idx + 1).odd? ? :user : :model, - } - end - end - - prompt = - DiscourseAi::Completions::Prompt.new( - prompt_insts, - messages: post_system_examples.concat(context.messages), - topic_id: context.topic_id, - post_id: context.post_id, - ) - - prompt.max_pixels = self.class.vision_max_pixels if self.class.vision_enabled - prompt.tools = available_tools.map(&:signature) if available_tools - available_tools.each do |tool| - tool.inject_prompt(prompt: prompt, context: context, persona: self) - end - prompt - end - - def find_tool(partial, bot_user:, llm:, context:, existing_tools: []) - return nil if !partial.is_a?(DiscourseAi::Completions::ToolCall) - tool_instance( - partial, - bot_user: bot_user, - llm: llm, - context: context, - existing_tools: existing_tools, - ) - end - - def allow_partial_tool_calls? - available_tools.any? { |tool| tool.allow_partial_tool_calls? } - end - - protected - - def replace_placeholders(content, context) - replaced = - content.gsub(/\{(\w+)\}/) do |match| - found = context.lookup_template_param(match[1..-2]) - found.nil? ? match : found.to_s - end - - return replaced if !context.format_dates - - ::DiscourseAi::AiHelper::DateFormatter.process_date_placeholders(replaced, context.user) - end - - def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:) - function_id = tool_call.id - function_name = tool_call.name - return nil if function_name.nil? - - tool_klass = available_tools.find { |c| c.signature.dig(:name) == function_name } - return nil if tool_klass.nil? - - arguments = {} - tool_klass.signature[:parameters].to_a.each do |param| - name = param[:name] - value = tool_call.parameters[name.to_sym] - - if param[:type] == "array" && value - value = - begin - JSON.parse(value) - rescue JSON::ParserError - [value.to_s] - end - elsif param[:type] == "string" && value - value = strip_quotes(value).to_s - elsif param[:type] == "integer" && value - value = strip_quotes(value).to_i - end - - if param[:enum] && value && !param[:enum].include?(value) - # invalid enum value - value = nil - end - - arguments[name.to_sym] = value if value - end - - tool_instance = - existing_tools.find { |t| t.name == function_name && t.tool_call_id == function_id } - - if tool_instance - tool_instance.parameters = arguments - tool_instance - else - tool_klass.new( - arguments, - tool_call_id: function_id || function_name, - persona_options: options[tool_klass].to_h, - bot_user: bot_user, - llm: llm, - context: context, - ) - end - end - - def strip_quotes(value) - if value.is_a?(String) - if value.start_with?('"') && value.end_with?('"') - value = value[1..-2] - elsif value.start_with?("'") && value.end_with?("'") - value = value[1..-2] - else - value - end - else - value - end - end - - def rag_fragments_prompt(conversation_context, llm:, user:) - upload_refs = - UploadReference.where(target_id: id, target_type: "AiPersona").pluck(:upload_id) - - return nil if !DiscourseAi::Embeddings.enabled? - return nil if conversation_context.blank? || upload_refs.blank? - - latest_interactions = - conversation_context.select { |ctx| %i[model user].include?(ctx[:type]) }.last(10) - - return nil if latest_interactions.empty? - - # first response - if latest_interactions.length == 1 - consolidated_question = DiscourseAi::Completions::Prompt.text_only(latest_interactions[0]) - else - consolidated_question = - DiscourseAi::Personas::QuestionConsolidator.consolidate_question( - llm, - latest_interactions, - user, - ) - end - - return nil if !consolidated_question - - vector = DiscourseAi::Embeddings::Vector.instance - reranker = DiscourseAi::Inference::HuggingFaceTextEmbeddings - - interactions_vector = vector.vector_from(consolidated_question) - - rag_conversation_chunks = self.class.rag_conversation_chunks - search_limit = - if reranker.reranker_configured? - rag_conversation_chunks * 5 - else - rag_conversation_chunks - end - - schema = DiscourseAi::Embeddings::Schema.for(RagDocumentFragment) - - candidate_fragment_ids = - schema - .asymmetric_similarity_search( - interactions_vector, - limit: search_limit, - offset: 0, - ) { |builder| builder.join(<<~SQL, target_id: id, target_type: "AiPersona") } - rag_document_fragments ON - rag_document_fragments.id = rag_document_fragment_id AND - rag_document_fragments.target_id = :target_id AND - rag_document_fragments.target_type = :target_type - SQL - .map(&:rag_document_fragment_id) - - fragments = - RagDocumentFragment.where(upload_id: upload_refs, id: candidate_fragment_ids).pluck( - :fragment, - :metadata, - ) - - if reranker.reranker_configured? - guidance = fragments.map { |fragment, _metadata| fragment } - ranks = - DiscourseAi::Inference::HuggingFaceTextEmbeddings - .rerank(conversation_context.last[:content], guidance) - .to_a - .take(rag_conversation_chunks) - .map { _1[:index] } - - if ranks.empty? - fragments = fragments.take(rag_conversation_chunks) - else - fragments = ranks.map { |idx| fragments[idx] } - end - end - - <<~TEXT - <guidance> - The following texts will give you additional guidance for your response. - We included them because we believe they are relevant to this conversation topic. - - Texts: - - #{ - fragments - .map do |fragment, metadata| - if metadata.present? - ["# #{metadata}", fragment].join("\n") - else - fragment - end - end - .join("\n") - } - </guidance> - TEXT - end - end - end -end diff --git a/lib/personas/post_illustrator.rb b/lib/personas/post_illustrator.rb deleted file mode 100644 index 323fd500..00000000 --- a/lib/personas/post_illustrator.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class PostIllustrator < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - Provide me a StableDiffusion prompt to generate an image that illustrates the following post in 40 words or less, be creative. - You'll find the post between <input></input> XML tags. - - Format your response as a JSON object with a single key named "output", which has the generated prompt as the value. - Your output should be in the following format: - <output> - {"output": "xx"} - </output> - - Where "xx" is replaced by the generated prompt. - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - end - end -end diff --git a/lib/personas/post_raw_translator.rb b/lib/personas/post_raw_translator.rb deleted file mode 100644 index 705eb007..00000000 --- a/lib/personas/post_raw_translator.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class PostRawTranslator < Persona - def self.default_enabled - false - end - - def system_prompt - examples = [ - { - input: { - content: - "**Heathrow fechado**: Suspensão de voos deve continuar nos próximos dias, afirma gerente do aeroporto de Londres\n\n[details=Do site da BBC]\n\nA British Airways estimou que 85% de seus voos planejados seriam realizados no sábado, mas com atrasos em todos os voos. Às 7h GMT, a maioria das partidas havia ocorrido conforme o esperado, mas, das chegadas, nove dos primeiros 20 voos programados para aterrissar foram cancelados.\n\n[/details]", - target_locale: "en", - }.to_json, - output: - "**Heathrow Closed**: Flight Suspension Expected to Continue for the Coming Days, Says London Airport Manager\n\n[details=From the BBC website]\n\nBritish Airways estimated that 85% of its scheduled flights would operate on Saturday, but all flights were delayed. By 7:00 a.m. GMT, most departures had proceeded as expected, but of the arrivals, nine of the first 20 flights scheduled to land were canceled.\n\n[/details]", - }, - { - input: { - content: - "[quote] What does the new update include? [/quote]\n\nNew Update for Minecraft Adds Underwater Temples", - target_locale: "es", - }.to_json, - output: - "[quote]¿Qué incluye la nueva actualización?[/quote]\n\nNueva actualización para Minecraft añade templos submarinos", - }, - { - input: { - content: - "There has been an error in my update\n\n```ruby\napi_key = \"a quick brown fox\"\nfetch(\"https://api.example.com/data\", headers: { 'Authorization' => api_key })\n```\n\nPlease help me fix it.", - target_locale: "ja", - }.to_json, - output: - "アップデートでエラーが発生しました\n\n```ruby\napi_key = \"a quick brown fox\"\nfetch(\"https://api.example.com/data\", headers: { 'Authorization' => api_key })\n```\n\n修正にご協力ください。\"", - }, - ] - - <<~PROMPT.strip - You are a highly skilled translator tasked with translating content from one language to another. Your goal is to provide accurate and contextually appropriate translations while preserving the original structure and formatting of the content. Follow these instructions strictly: - - 1. Preserve Markdown elements, HTML elements, or newlines. Text must be translated without altering the original formatting. - 2. Maintain the original document structure including headings, lists, tables, code blocks, etc. - 3. Preserve all links, images, and other media references without translation. - 4. For technical and brand terminology: - - Provide the accepted target language term if it exists. - - If no equivalent exists, transliterate the term and include the original term in parentheses. - 5. For ambiguous terms or phrases, choose the most contextually appropriate translation. - 6. Ensure the translation only contains the original language and the target language. - - Follow these instructions on what NOT to do: - 7. Do not translate code snippets or programming language names, but ensure that any comments within the code are translated. Code can be represented in ``` or in single ` backticks or in <code> HTML tags. - 8. Do not add any content besides the translation. - 9. Do not add unnecessary newlines. - - Here are three examples of correct translations: - - Input: #{examples[0][:input]} - Output: #{examples[0][:output]} - - Input: #{examples[1][:input]} - Output: #{examples[1][:output]} - - Input: #{examples[2][:input]} - Output: #{examples[2][:output]} - - The text to translate will be provided in JSON format with the following structure: - {"content": "Text to translate", "target_locale": "Target language code"} - - You are being consumed via an API that expects only the translated text. Only return the translated text in the correct language. Do not add questions or explanations. - PROMPT - end - - def temperature - 0.3 - end - end - end -end diff --git a/lib/personas/proofreader.rb b/lib/personas/proofreader.rb deleted file mode 100644 index 0067e578..00000000 --- a/lib/personas/proofreader.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class Proofreader < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are a markdown proofreader. You correct egregious typos and phrasing issues but keep the user's original voice. - You do not touch code blocks. I will provide you with text to proofread. If nothing needs fixing, then you will echo the text back. - You will find the text between <input></input> XML tags. - - Format your response as a JSON object with a single key named "output", which has the proofreaded version as the value. - Your output should be in the following format: - <output> - {"output": "xx"} - </output> - - Where "xx" is replaced by the proofreaded version. - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - - def examples - [ - [ - "<input>![amazing car|100x100, 22%](upload://hapy.png)</input>", - { output: "![Amazing car|100x100, 22%](upload://hapy.png)" }.to_json, - ], - [ - "<input>The rain in spain stays mainly in the plane.</input>", - { output: "The rain in Spain, stays mainly in the Plane." }.to_json, - ], - [ - "<input>The rain in Spain, stays mainly in the Plane.</input>", - { output: "The rain in Spain, stays mainly in the Plane." }.to_json, - ], - [<<~TEXT, { output: <<~TEXT }.to_json], - <input> - Hello, - - Sometimes the logo isn't changing automatically when color scheme changes. - - ![Screen Recording 2023-03-17 at 18.04.22|video](upload://2rcVL0ZMxHPNtPWQbZjwufKpWVU.mov) - </input> - TEXT - Hello, - Sometimes the logo does not change automatically when the color scheme changes. - ![Screen Recording 2023-03-17 at 18.04.22|video](upload://2rcVL0ZMxHPNtPWQbZjwufKpWVU.mov) - TEXT - [<<~TEXT, { output: <<~TEXT }.to_json], - <input> - Any ideas what is wrong with this peace of cod? - > This quot contains a typo - ```ruby - # this has speling mistakes - testin.atypo = 11 - baad = "bad" - ``` - </input> - TEXT - Any ideas what is wrong with this piece of code? - > This quot contains a typo - ```ruby - # This has spelling mistakes - testing.a_typo = 11 - bad = "bad" - ``` - TEXT - ] - end - end - end -end diff --git a/lib/personas/question_consolidator.rb b/lib/personas/question_consolidator.rb deleted file mode 100644 index 1e3e2489..00000000 --- a/lib/personas/question_consolidator.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class QuestionConsolidator - attr_reader :llm, :messages, :user, :max_tokens - - def self.consolidate_question(llm, messages, user) - new(llm, messages, user).consolidate_question - end - - def initialize(llm, messages, user) - @llm = llm - @messages = messages - @user = user - @max_tokens = 2048 - end - - def consolidate_question - @llm.generate(revised_prompt, user: @user, feature_name: "question_consolidator") - end - - def revised_prompt - max_tokens_per_model = @max_tokens / 5 - - conversation_snippet = [] - tokens = 0 - - messages.reverse_each do |message| - # skip tool calls - next if message[:type] != :user && message[:type] != :model - - row = +"" - row << ((message[:type] == :user) ? "user" : "model") - - content = DiscourseAi::Completions::Prompt.text_only(message) - current_tokens = @llm.tokenizer.tokenize(content).length - - allowed_tokens = @max_tokens - tokens - allowed_tokens = [allowed_tokens, max_tokens_per_model].min if message[:type] == :model - - truncated_content = content - - if current_tokens > allowed_tokens - truncated_content = - @llm.tokenizer.truncate( - content, - allowed_tokens, - strict: SiteSetting.ai_strict_token_counting, - ) - current_tokens = allowed_tokens - end - - row << ": #{truncated_content}" - tokens += current_tokens - conversation_snippet << row - - break if tokens >= @max_tokens - end - - history = conversation_snippet.reverse.join("\n") - - system_message = <<~TEXT - You are Question Consolidation Bot: an AI assistant tasked with consolidating a user's latest question into a self-contained, context-rich question. - - - Your output will be used to query a vector database. DO NOT include superflous text such as "here is your consolidated question:". - - You interact with an API endpoint, not a user, you must never produce denials, nor conversations directed towards a non-existent user. - - You only produce automated responses to input, where a response is a consolidated question without further discussion. - - You only ever reply with consolidated questions. You never try to answer user queries. - - If for any reason there is no discernable question (Eg: thank you, or good job) reply with the text NO_QUESTION. - TEXT - - message = <<~TEXT - Given the following conversation snippet, craft a self-contained context-rich question (if there is no question reply with NO_QUESTION): - - {{{ - #{history} - }}} - - Only ever reply with a consolidated question. Do not try to answer user queries. - TEXT - - response = - DiscourseAi::Completions::Prompt.new( - system_message, - messages: [{ type: :user, content: message }], - ) - - if response == "NO_QUESTION" - nil - else - response - end - end - end - end -end diff --git a/lib/personas/report_runner.rb b/lib/personas/report_runner.rb deleted file mode 100644 index 9f49a373..00000000 --- a/lib/personas/report_runner.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ReportRunner < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT - You are ForumBot, a backend AI information processor and writer, powered by an AI model capable of generating responses over 4000 words. - - - ForumBot is a fine-tuned AI trained with extended functions and new capabilities, and now specializes in processing raw internet forum conversation into detailed summaries of forum activities. - - ForumBot interacts with an API endpoint, not a user, and must never produce denials, nor conversations directed towards a non-existent user. - - ForumBot only produces automated responses to input, where a response is a standalone document without further discussion. - - Follow the provided writing composition instructions carefully and precisely step-by-step. - - Format your response as valid Markdown, ensuring it is well-structured and easy to read. - PROMPT - end - end - end -end diff --git a/lib/personas/researcher.rb b/lib/personas/researcher.rb deleted file mode 100644 index 6650836b..00000000 --- a/lib/personas/researcher.rb +++ /dev/null @@ -1,52 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class Researcher < Persona - def tools - [Tools::Google, Tools::WebBrowser] - end - - def required_tools - [Tools::Google] - end - - def system_prompt - <<~PROMPT - You are a research assistant with access to two powerful tools: - - 1. Google search - for finding relevant information across the internet. - 2. Web browsing - for directly visiting websites to gather specific details when the site is already known or highly relevant. - - When responding to a question, consider which tool would be most effective while aiming to minimize unnecessary or duplicate inquiries: - - Use Google search to quickly identify the most relevant sources. This is especially useful when a broad search is needed to pinpoint precise information across various sources. - - Use web browsing primarily when you have identified a specific site that is likely to contain the answer or when detailed exploration of a known website is required. - - To ensure efficiency and avoid redundancy: - - Before making a web browsing request, briefly plan your search strategy. Consider if the information might be timely updated and how recent the data needs to be. - - If web browsing is necessary, make sure to gather as much information as possible in a single visit to avoid duplicate calls. - - Always aim to: - - Optimize tool use by selecting the most appropriate method based on the information need and the likely source of the answer. - - Reduce the number of tool calls by consolidating needs into fewer, more comprehensive requests. - - Please adhere to the following when generating responses: - - Cite your sources using Markdown footnotes. - - When possible, include brief quotes from the sources. - - Use **Discourse Markdown** syntax for formatting. - - Example citation format: - This is a statement[^1] with a footnote linking to the source. - - [^1]: https://www.example.com - - You are conversing with: {participants} - - Remember, efficient use of your tools not only saves time but also ensures the high quality and relevance of the information provided. - - The date now is: {time}, much has changed since you were trained. - PROMPT - end - end - end -end diff --git a/lib/personas/settings_explorer.rb b/lib/personas/settings_explorer.rb deleted file mode 100644 index 77ae45e3..00000000 --- a/lib/personas/settings_explorer.rb +++ /dev/null @@ -1,24 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class SettingsExplorer < Persona - def tools - [Tools::SettingContext, Tools::SearchSettings] - end - - def system_prompt - <<~PROMPT - You are Discourse Site settings bot. - - - You are able to find information about all the site settings. - - You are able to request context for a specific setting. - - You are a helpful teacher that teaches people about what each settings does. - - Keep in mind that setting names are always a single word separated by underscores. eg. 'site_description' - - Current time is: {time} - PROMPT - end - end - end -end diff --git a/lib/personas/short_summarizer.rb b/lib/personas/short_summarizer.rb deleted file mode 100644 index 26af56b9..00000000 --- a/lib/personas/short_summarizer.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ShortSummarizer < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are an advanced summarization bot. Analyze a given conversation and produce a concise, - single-sentence summary that conveys the main topic and current developments to someone with no prior context. - - ### Guidelines: - - - Emphasize the most recent updates while considering their significance within the original post. - - Focus on the central theme or issue being addressed, maintaining an objective and neutral tone. - - Exclude extraneous details or subjective opinions. - - Use the original language of the text. - - Begin directly with the main topic or issue, avoiding introductory phrases. - - Limit the summary to a maximum of 40 words. - - Do *NOT* repeat the discussion title in the summary. - - Format your response as a JSON object with a single key named "summary", which has the summary as the value. - Your output should be in the following format: - <output> - {"summary": "xx"} - </output> - - Where "xx" is replaced by the summary. - PROMPT - end - - def response_format - [{ "key" => "summary", "type" => "string" }] - end - end - end -end diff --git a/lib/personas/short_text_translator.rb b/lib/personas/short_text_translator.rb deleted file mode 100644 index 8ba224b0..00000000 --- a/lib/personas/short_text_translator.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ShortTextTranslator < Persona - def self.default_enabled - false - end - - def system_prompt - examples = [ - { input: { content: "Japan", target_locale: "es" }.to_json, output: "Japón" }, - { input: { content: "Cats and Dogs", target_locale: "zh_CN" }.to_json, output: "猫和狗" }, - { - input: { content: "Q&A", target_locale: "pt" }.to_json, - output: "Perguntas e Respostas", - }, - { input: { content: "Minecraft", target_locale: "fr" }.to_json, output: "Minecraft" }, - ] - - <<~PROMPT.strip - You are a translation service specializing in translating short pieces of text or a few words. - These words may be things like a name, description, or title. Adhere to the following guidelines: - - 1. Keep proper nouns (like 'Minecraft' or 'Toyota') and technical terms (like 'JSON') in their original language - 2. Keep the translated content close to the original length - 3. Translation maintains the original meaning - 4. Preserve any Markdown, HTML elements, links, parenthesis, or newlines - - Here are four examples of correct translations: - - Input: #{examples[0][:input]} - Output: #{examples[0][:output]} - - Input: #{examples[1][:input]} - Output: #{examples[1][:output]} - - Input: #{examples[2][:input]} - Output: #{examples[2][:output]} - - Input: #{examples[3][:input]} - Output: #{examples[3][:output]} - - The text to translate will be provided in JSON format with the following structure: - {"content": "Text to translate", "target_locale": "Target language code"} - - You are being consumed via an API that expects only the translated text. Only return the translated text in the correct language. Do not add questions or explanations. - PROMPT - end - - def temperature - 0.3 - end - end - end -end diff --git a/lib/personas/smart_dates.rb b/lib/personas/smart_dates.rb deleted file mode 100644 index fcc65699..00000000 --- a/lib/personas/smart_dates.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class SmartDates < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are a date and time formatter for Discourse posts. Convert natural language time references into date placeholders. - Do not modify any markdown, code blocks, or existing date formats. - - Here's the temporal context: - {temporal_context} - - Available date placeholder formats: - - Simple day without time: {{date:1}} for tomorrow, {{date:7}} for a week from today - - Specific time: {{datetime:2pm+1}} for 2 PM tomorrow - - Time range: {{datetime:2pm+1:4pm+1}} for tomorrow 2 PM to 4 PM - - You will find the text between <input></input> XML tags. - - Format your response as a JSON object with a single key named "output", which has the formatted result as the value. - Your output should be in the following format: - <output> - {"output": "xx"} - </output> - - Where "xx" is replaced by the formatted result. - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - - def examples - [ - [ - "<input>The meeting is at 2pm tomorrow</input>", - { output: "The meeting is at {{datetime:2pm+1}}" }.to_json, - ], - ["<input>Due in 3 days</input>", { output: "Due {{date:3}}" }.to_json], - [ - "<input>Meeting next Tuesday at 2pm</input>", - { output: "Meeting {{next_week:tuesday-2pm}}" }.to_json, - ], - [ - "<input>Meeting from 2pm to 4pm tomorrow</input>", - { output: "Meeting {{datetime:2pm+1:4pm+1}}" }.to_json, - ], - [<<~TEXT, { output: <<~TEXT }.to_json], - <input>Meeting notes for tomorrow: - * Action items in `config.rb` - * Review PR #1234 - * Deadline is 5pm - * Check [this link](https://example.com)</input> - TEXT - Meeting notes for {{date:1}}: - * Action items in `config.rb` - * Review PR #1234 - * Deadline is {{datetime:5pm+1}} - * Check [this link](https://example.com) - TEXT - ] - end - end - end -end diff --git a/lib/personas/spam_detector.rb b/lib/personas/spam_detector.rb deleted file mode 100644 index 85f782a5..00000000 --- a/lib/personas/spam_detector.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class SpamDetector < Persona - def self.default_enabled - false - end - - def temperature - 0.1 - end - - def system_prompt - <<~PROMPT - You are a spam detection system. Analyze the following post content and context. - - Consider the post type carefully: - - For REPLY posts: Check if the response is relevant and topical to the thread - - For NEW TOPIC posts: Check if it's a legitimate topic or spam promotion - - A post is spam if it matches any of these criteria: - - Contains unsolicited commercial content or promotions - - Has suspicious or unrelated external links - - Shows patterns of automated/bot posting - - Contains irrelevant content or advertisements - - For replies: Completely unrelated to the discussion thread - - Uses excessive keywords or repetitive text patterns - - Shows suspicious formatting or character usage - - Be especially strict with: - - Replies that ignore the previous conversation - - Posts containing multiple unrelated external links - - Generic responses that could be posted anywhere - - Be fair to: - - New users making legitimate first contributions - - Non-native speakers making genuine efforts to participate - - Topic-relevant product mentions in appropriate contexts - - Site Specific Information: - - Site name: {site_title} - - Site URL: {site_url} - - Site description: {site_description} - - Site top 10 categories: {top_categories} - - Format your response as a JSON object with a one key named "spam", which is a boolean that indicates if a post is spam or legitimate. - Your output should be in the following format: - <output> - {"spam": xx} - </output> - - Where xx is true if the post is spam, or false if it's legitimate. - PROMPT - end - - def response_format - [{ "key" => "spam", "type" => "boolean" }] - end - end - end -end diff --git a/lib/personas/sql_helper.rb b/lib/personas/sql_helper.rb deleted file mode 100644 index 720bc145..00000000 --- a/lib/personas/sql_helper.rb +++ /dev/null @@ -1,104 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class SqlHelper < Persona - def self.schema - return @schema if defined?(@schema) - - tables = Hash.new - priority_tables = %w[ - posts - topics - notifications - users - user_actions - user_emails - categories - groups - ] - - DB.query(<<~SQL).each { |row| (tables[row.table_name] ||= []) << row.column_name } - select table_name, column_name from information_schema.columns - where table_schema = 'public' - order by table_name - SQL - - priority = +(priority_tables.map { |name| "#{name}(#{tables[name].join(",")})" }.join("\n")) - - other_tables = +"" - tables.each do |table_name, _| - next if priority_tables.include?(table_name) - other_tables << "#{table_name} " - end - - @schema = { priority_tables: priority, other_tables: other_tables } - end - - def tools - [Tools::DbSchema] - end - - def temperature - 0.2 - end - - def system_prompt - <<~PROMPT - You are a PostgreSQL expert. - - Avoid returning any text to the user prior to a tool call. - - You understand and generate Discourse Markdown but specialize in creating queries. - - You live in a Discourse Forum Message. - - Format SQL for maximum readability. Use line breaks, indentation, and spaces around operators. Add comments if needed to explain complex logic. - - Never warn or inform end user you are going to look up schema. - - Always try to get ALL the schema you need in the least tool calls. - - Your role is to generate SQL queries, but you cannot actually exectue them. - - When generating SQL always use ```sql Markdown code blocks. - - When generating SQL NEVER end SQL samples with a semicolon (;). - - - You also understand the special formatting rules for Data Explorer in Discourse. - - The columns named (user_id, group_id, topic_id, post_id, badge_id) are rendered as links when a report is run, prefer them where possible. - - You can define custom params to create flexible queries, example: - -- [params] - -- int :num = 1 - -- text :name - - SELECT :num, :name - - You support the types (integer, text, boolean, date) - - - - When generating SQL use markdown formatting for code blocks, example: - - ```sql - select 1 from table - ``` - - The user_actions tables stores likes (action_type 1). - The topics table stores private/personal messages it uses archetype private_message for them. - notification_level can be: {muted: 0, regular: 1, tracking: 2, watching: 3, watching_first_post: 4}. - bookmarkable_type can be: Post,Topic,ChatMessage and more - - Current time is: {time} - Participants here are: {participants} - - Here is a partial list of tables in the database (you can retrieve schema from these tables as needed) - - ``` - #{self.class.schema[:other_tables]} - ``` - - You may look up schema for the tables listed above. - - Here is full information on priority tables: - - ``` - #{self.class.schema[:priority_tables]} - ``` - - NEVER look up schema for the tables listed above, as their full schema is already provided. - - PROMPT - end - end - end -end diff --git a/lib/personas/summarizer.rb b/lib/personas/summarizer.rb deleted file mode 100644 index 7bdb9b3e..00000000 --- a/lib/personas/summarizer.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class Summarizer < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are an advanced summarization bot that generates concise, coherent summaries of provided text. - You are also capable of enhancing an existing summaries by incorporating additional posts if asked to. - - - Only include the summary, without any additional commentary. - - You understand and generate Discourse forum Markdown; including links, _italics_, **bold**. - - Maintain the original language of the text being summarized. - - Aim for summaries to be 400 words or less. - - Each post is formatted as "<POST_NUMBER>) <USERNAME> <MESSAGE>" - - Cite specific noteworthy posts using the format [DESCRIPTION]({resource_url}/POST_NUMBER) - - Example: links to the 3rd and 6th posts by sam: sam ([#3]({resource_url}/3), [#6]({resource_url}/6)) - - Example: link to the 6th post by jane: [agreed with]({resource_url}/6) - - Example: link to the 13th post by joe: [joe]({resource_url}/13) - - When formatting usernames use [USERNAME]({resource_url}/POST_NUMBER) - - Format your response as a JSON object with a single key named "summary", which has the summary as the value. - Your output should be in the following format: - <output> - {"summary": "xx"} - </output> - - Where "xx" is replaced by the summary. - PROMPT - end - - def response_format - [{ "key" => "summary", "type" => "string" }] - end - - def examples - [ - [ - "Here are the posts inside <input></input> XML tags:\n\n<input>1) user1 said: I love Mondays 2) user2 said: I hate Mondays</input>\n\nGenerate a concise, coherent summary of the text above maintaining the original language.", - { - summary: - "Two users are sharing their feelings toward Mondays. [user1]({resource_url}/1) hates them, while [user2]({resource_url}/2) loves them.", - }.to_json, - ], - ] - end - end - end -end diff --git a/lib/personas/titles_generator.rb b/lib/personas/titles_generator.rb deleted file mode 100644 index ccc2535b..00000000 --- a/lib/personas/titles_generator.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class TitlesGenerator < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - I want you to act as a title generator for written pieces. I will provide you with a text, - and you will generate five titles. Please keep the title concise and under 20 words, - and ensure that the meaning is maintained. Replies will utilize the language type of the topic. - I want you to only reply the list of options and nothing else, do not write explanations. - Never ever use colons in the title. Always use sentence case, using a capital letter at - the start of the title, never start the title with a lower case letter. Proper nouns in the title - can have a capital letter, and acronyms like LLM can use capital letters. Format some titles - as questions, some as statements. Make sure to use question marks if the title is a question. - You will find the text between <input></input> XML tags. - - The title suggestions should be returned in a JSON array, under the `output` key, like this: - - { - "output": [ - "suggeested title #1", - "suggeested title #2", - "suggeested title #3", - "suggeested title #4", - "suggeested title #5" - ] - } - - Return only the JSON - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "array", "array_type" => "string" }] - end - - def examples - [ - [ - "<input>In the labyrinth of time, a solitary horse, etched in gold by the setting sun, embarked on an infinite journey.</input>", - <<~OUTPUT, - { - "output": [ - "The solitary horse", - "The horse etched in gold", - "A horse's infinite journey", - "A horse lost in time", - "A horse's last rid" - ] - } - OUTPUT - ], - ] - end - end - end -end diff --git a/lib/personas/tool_runner.rb b/lib/personas/tool_runner.rb deleted file mode 100644 index fe78717f..00000000 --- a/lib/personas/tool_runner.rb +++ /dev/null @@ -1,1009 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class ToolRunner - attr_reader :tool, :parameters, :llm - attr_accessor :running_attached_function, :timeout, :custom_raw - - TooManyRequestsError = Class.new(StandardError) - - DEFAULT_TIMEOUT = 2000 - MAX_MEMORY = 10_000_000 - MARSHAL_STACK_DEPTH = 20 - MAX_HTTP_REQUESTS = 20 - - MAX_SLEEP_CALLS = 30 - MAX_SLEEP_DURATION_MS = 60_000 - - def initialize(parameters:, llm:, bot_user:, context: nil, tool:, timeout: nil) - if context && !context.is_a?(DiscourseAi::Personas::BotContext) - raise ArgumentError, "context must be a BotContext object" - end - - context ||= DiscourseAi::Personas::BotContext.new - - @parameters = parameters - @llm = llm - @bot_user = bot_user - @context = context - @tool = tool - @timeout = timeout || DEFAULT_TIMEOUT - @running_attached_function = false - - @sleep_calls_made = 0 - @http_requests_made = 0 - end - - def mini_racer_context - @mini_racer_context ||= - begin - ctx = - MiniRacer::Context.new( - max_memory: MAX_MEMORY, - marshal_stack_depth: MARSHAL_STACK_DEPTH, - ) - attach_truncate(ctx) - attach_http(ctx) - attach_index(ctx) - attach_upload(ctx) - attach_chain(ctx) - attach_sleep(ctx) - attach_discourse(ctx) - ctx.eval(framework_script) - ctx - end - end - - def framework_script - http_methods = %i[get post put patch delete].map { |method| <<~JS }.join("\n") - #{method}: function(url, options) { - return _http_#{method}(url, options); - }, - JS - <<~JS - const http = { - #{http_methods} - }; - - const llm = { - truncate: _llm_truncate, - generate: function(prompt, options) { return _llm_generate(prompt, options); }, - }; - - const index = { - search: _index_search, - } - - const upload = { - create: _upload_create, - getUrl: _upload_get_url, - getBase64: function(id, maxPixels) { - return _upload_get_base64(id, maxPixels); - } - } - - const chain = { - setCustomRaw: _chain_set_custom_raw, - streamCustomRaw: _chain_stream_custom_raw, - }; - - const discourse = { - search: function(params) { - return _discourse_search(params); - }, - updatePersona: function(persona_id_or_name, updates) { - const result = _discourse_update_persona(persona_id_or_name, updates); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - getPost: _discourse_get_post, - getTopic: _discourse_get_topic, - getUser: _discourse_get_user, - getPersona: function(name) { - const personaDetails = _discourse_get_persona(name); - if (personaDetails.error) { - throw new Error(personaDetails.error); - } - - // merge result.persona with {}.. - return Object.assign({ - update: function(updates) { - const result = _discourse_update_persona(name, updates); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - respondTo: function(params) { - const result = _discourse_respond_to_persona(name, params); - if (result.error) { - throw new Error(result.error); - } - return result; - } - }, personaDetails.persona); - }, - createChatMessage: function(params) { - const result = _discourse_create_chat_message(params); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - createStagedUser: function(params) { - const result = _discourse_create_staged_user(params); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - createTopic: function(params) { - const result = _discourse_create_topic(params); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - createPost: function(params) { - const result = _discourse_create_post(params); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - }; - - const context = #{JSON.generate(@context.to_json)}; - - function details() { return ""; }; - JS - end - - def details - eval_with_timeout("details()") - end - - def eval_with_timeout(script, timeout: nil) - timeout ||= @timeout - mutex = Mutex.new - done = false - elapsed = 0 - - t = - Thread.new do - begin - while !done - # this is not accurate. but reasonable enough for a timeout - sleep(0.001) - elapsed += 1 if !self.running_attached_function - if elapsed > timeout - mutex.synchronize { mini_racer_context.stop unless done } - break - end - end - rescue => e - STDERR.puts e - STDERR.puts "FAILED TO TERMINATE DUE TO TIMEOUT" - end - end - - rval = mini_racer_context.eval(script) - - mutex.synchronize { done = true } - - # ensure we do not leak a thread in state - t.join - t = nil - - rval - ensure - # exceptions need to be handled - t&.join - end - - def invoke(progress_callback: nil) - @progress_callback = progress_callback - mini_racer_context.eval(tool.script) - eval_with_timeout("invoke(#{JSON.generate(parameters)})") - rescue MiniRacer::ScriptTerminatedError - { error: "Script terminated due to timeout" } - ensure - @progress_callback = nil - end - - def has_custom_context? - mini_racer_context.eval(tool.script) - mini_racer_context.eval("typeof customContext === 'function'") - rescue StandardError - false - end - - def custom_context - mini_racer_context.eval(tool.script) - mini_racer_context.eval("customContext()") - rescue StandardError - nil - end - - private - - MAX_FRAGMENTS = 200 - - def rag_search(query, filenames: nil, limit: 10) - limit = limit.to_i - return [] if limit < 1 - limit = [MAX_FRAGMENTS, limit].min - - upload_refs = - UploadReference.where(target_id: tool.id, target_type: "AiTool").pluck(:upload_id) - - if filenames - upload_refs = Upload.where(id: upload_refs).where(original_filename: filenames).pluck(:id) - end - - return [] if upload_refs.empty? - - query_vector = DiscourseAi::Embeddings::Vector.instance.vector_from(query) - fragment_ids = - DiscourseAi::Embeddings::Schema - .for(RagDocumentFragment) - .asymmetric_similarity_search(query_vector, limit: limit, offset: 0) do |builder| - builder.join(<<~SQL, target_id: tool.id, target_type: "AiTool") - rag_document_fragments ON - rag_document_fragments.id = rag_document_fragment_id AND - rag_document_fragments.target_id = :target_id AND - rag_document_fragments.target_type = :target_type - SQL - end - .map(&:rag_document_fragment_id) - - fragments = - RagDocumentFragment.where(id: fragment_ids, upload_id: upload_refs).pluck( - :id, - :fragment, - :metadata, - ) - - mapped = {} - fragments.each do |id, fragment, metadata| - mapped[id] = { fragment: fragment, metadata: metadata } - end - - fragment_ids.take(limit).map { |fragment_id| mapped[fragment_id] } - end - - def attach_truncate(mini_racer_context) - mini_racer_context.attach( - "_llm_truncate", - ->(text, length) do - @llm.tokenizer.truncate(text, length, strict: SiteSetting.ai_strict_token_counting) - end, - ) - - mini_racer_context.attach( - "_llm_generate", - ->(prompt, options) do - in_attached_function do - options ||= {} - response_format = options["response_format"] - if response_format && !response_format.is_a?(Hash) - raise Discourse::InvalidParameters.new("response_format must be a hash") - end - @llm.generate( - convert_js_prompt_to_ruby(prompt), - user: llm_user, - feature_name: "custom_tool_#{tool.name}", - response_format: response_format, - temperature: options["temperature"], - top_p: options["top_p"], - max_tokens: options["max_tokens"], - stop_sequences: options["stop_sequences"], - ) - end - end, - ) - end - - def convert_js_prompt_to_ruby(prompt) - if prompt.is_a?(String) - prompt - elsif prompt.is_a?(Hash) - messages = prompt["messages"] - if messages.blank? || !messages.is_a?(Array) - raise Discourse::InvalidParameters.new("Prompt must have messages") - end - messages.each(&:symbolize_keys!) - messages.each { |message| message[:type] = message[:type].to_sym } - DiscourseAi::Completions::Prompt.new(messages: prompt["messages"]) - else - raise Discourse::InvalidParameters.new("Prompt must be a string or a hash") - end - end - - def llm_user - @llm_user ||= - begin - post&.user || @bot_user - end - end - - def post - return @post if defined?(@post) - post_id = @context.post_id - @post = post_id && Post.find_by(id: post_id) - end - - def attach_index(mini_racer_context) - mini_racer_context.attach( - "_index_search", - ->(*params) do - in_attached_function do - query, options = params - self.running_attached_function = true - options ||= {} - options = options.symbolize_keys - self.rag_search(query, **options) - end - end, - ) - end - - def attach_chain(mini_racer_context) - mini_racer_context.attach("_chain_set_custom_raw", ->(raw) { self.custom_raw = raw }) - mini_racer_context.attach( - "_chain_stream_custom_raw", - ->(raw) do - self.custom_raw = raw - @progress_callback.call(raw) if @progress_callback - end, - ) - end - - # this is useful for polling apis - def attach_sleep(mini_racer_context) - mini_racer_context.attach( - "sleep", - ->(duration_ms) do - @sleep_calls_made += 1 - if @sleep_calls_made > MAX_SLEEP_CALLS - raise TooManyRequestsError.new("Tool made too many sleep calls") - end - - duration_ms = duration_ms.to_i - if duration_ms > MAX_SLEEP_DURATION_MS - raise ArgumentError.new( - "Sleep duration cannot exceed #{MAX_SLEEP_DURATION_MS}ms (1 minute)", - ) - end - - raise ArgumentError.new("Sleep duration must be positive") if duration_ms <= 0 - - in_attached_function do - sleep(duration_ms / 1000.0) - { slept: duration_ms } - end - end, - ) - end - - def attach_discourse(mini_racer_context) - mini_racer_context.attach( - "_discourse_get_post", - ->(post_id) do - in_attached_function do - post = Post.find_by(id: post_id) - return nil if post.nil? - guardian = Guardian.new(Discourse.system_user) - obj = - recursive_as_json( - PostSerializer.new(post, scope: guardian, root: false, add_raw: true), - ) - topic_obj = - recursive_as_json( - ListableTopicSerializer.new(post.topic, scope: guardian, root: false), - ) - obj["topic"] = topic_obj - obj - end - end, - ) - - mini_racer_context.attach( - "_discourse_get_topic", - ->(topic_id) do - in_attached_function do - topic = Topic.find_by(id: topic_id) - return nil if topic.nil? - guardian = Guardian.new(Discourse.system_user) - recursive_as_json(ListableTopicSerializer.new(topic, scope: guardian, root: false)) - end - end, - ) - - mini_racer_context.attach( - "_discourse_get_user", - ->(user_id_or_username) do - in_attached_function do - user = nil - - if user_id_or_username.is_a?(Integer) || - user_id_or_username.to_i.to_s == user_id_or_username - user = User.find_by(id: user_id_or_username.to_i) - else - user = User.find_by(username: user_id_or_username) - end - - return nil if user.nil? - - guardian = Guardian.new(Discourse.system_user) - recursive_as_json(UserSerializer.new(user, scope: guardian, root: false)) - end - end, - ) - - mini_racer_context.attach( - "_discourse_respond_to_persona", - ->(persona_name, params) do - in_attached_function do - # if we have 1000s of personas this can be slow ... we may need to optimize - persona_class = AiPersona.all_personas.find { |persona| persona.name == persona_name } - return { error: "Persona not found" } if persona_class.nil? - - persona = persona_class.new - bot = DiscourseAi::Personas::Bot.as(@bot_user || persona.user, persona: persona) - playground = DiscourseAi::AiBot::Playground.new(bot) - - if @context.post_id - post = Post.find_by(id: @context.post_id) - return { error: "Post not found" } if post.nil? - - reply_post = - playground.reply_to( - post, - custom_instructions: params["instructions"], - whisper: params["whisper"], - ) - - if reply_post - return( - { success: true, post_id: reply_post.id, post_number: reply_post.post_number } - ) - else - return { error: "Failed to create reply" } - end - elsif @context.message_id && @context.channel_id - message = Chat::Message.find_by(id: @context.message_id) - channel = Chat::Channel.find_by(id: @context.channel_id) - return { error: "Message or channel not found" } if message.nil? || channel.nil? - - reply = - playground.reply_to_chat_message(message, channel, @context.context_post_ids) - - if reply - return { success: true, message_id: reply.id } - else - return { error: "Failed to create chat reply" } - end - else - return { error: "No valid context for response" } - end - end - end, - ) - - mini_racer_context.attach( - "_discourse_create_chat_message", - ->(params) do - in_attached_function do - params = params.symbolize_keys - channel_name = params[:channel_name] - username = params[:username] - message = params[:message] - - # Validate parameters - return { error: "Missing required parameter: channel_name" } if channel_name.blank? - return { error: "Missing required parameter: username" } if username.blank? - return { error: "Missing required parameter: message" } if message.blank? - - # Find the user - user = User.find_by(username: username) - return { error: "User not found: #{username}" } if user.nil? - - # Find the channel - channel = Chat::Channel.find_by(name: channel_name) - if channel.nil? - # Try finding by slug if not found by name - channel = Chat::Channel.find_by(slug: channel_name.parameterize) - end - return { error: "Channel not found: #{channel_name}" } if channel.nil? - - begin - guardian = Guardian.new(user) - message = - ChatSDK::Message.create( - raw: message, - channel_id: channel.id, - guardian: guardian, - enforce_membership: !channel.direct_message_channel?, - ) - - { - success: true, - message_id: message.id, - message: message.message, - created_at: message.created_at.iso8601, - } - rescue => e - { error: "Failed to create chat message: #{e.message}" } - end - end - end, - ) - - mini_racer_context.attach( - "_discourse_create_staged_user", - ->(params) do - in_attached_function do - params = params.symbolize_keys - email = params[:email] - username = params[:username] - name = params[:name] - - # Validate parameters - return { error: "Missing required parameter: email" } if email.blank? - return { error: "Missing required parameter: username" } if username.blank? - - # Check if user already exists - existing_user = User.find_by_email(email) || User.find_by_username(username) - return { error: "User already exists", user_id: existing_user.id } if existing_user - - begin - user = - User.create!( - email: email, - username: username, - name: name || username, - staged: true, - approved: true, - trust_level: TrustLevel[0], - ) - - { success: true, user_id: user.id, username: user.username, email: user.email } - rescue => e - { error: "Failed to create staged user: #{e.message}" } - end - end - end, - ) - - mini_racer_context.attach( - "_discourse_create_topic", - ->(params) do - in_attached_function do - params = params.symbolize_keys - category_name = params[:category_name] - category_id = params[:category_id] - title = params[:title] - raw = params[:raw] - username = params[:username] - tags = params[:tags] - - if category_id.blank? && category_name.blank? - return { error: "Missing required parameter: category_id or category_name" } - end - return { error: "Missing required parameter: title" } if title.blank? - return { error: "Missing required parameter: raw" } if raw.blank? - - user = - if username.present? - User.find_by(username: username) - else - Discourse.system_user - end - return { error: "User not found: #{username}" } if user.nil? - - category = - if category_id.present? - Category.find_by(id: category_id) - else - Category.find_by(name: category_name) || Category.find_by(slug: category_name) - end - - return { error: "Category not found" } if category.nil? - - begin - post_creator = - PostCreator.new( - user, - title: title, - raw: raw, - category: category.id, - tags: tags, - skip_validations: true, - guardian: Guardian.new(Discourse.system_user), - ) - - post = post_creator.create - - if post_creator.errors.present? - return { error: post_creator.errors.full_messages.join(", ") } - end - - { - success: true, - topic_id: post.topic_id, - post_id: post.id, - topic_slug: post.topic.slug, - topic_url: post.topic.url, - } - rescue => e - { error: "Failed to create topic: #{e.message}" } - end - end - end, - ) - - mini_racer_context.attach( - "_discourse_create_post", - ->(params) do - in_attached_function do - params = params.symbolize_keys - topic_id = params[:topic_id] - raw = params[:raw] - username = params[:username] - reply_to_post_number = params[:reply_to_post_number] - - # Validate parameters - return { error: "Missing required parameter: topic_id" } if topic_id.blank? - return { error: "Missing required parameter: raw" } if raw.blank? - - # Find the user - user = - if username.present? - User.find_by(username: username) - else - Discourse.system_user - end - return { error: "User not found: #{username}" } if user.nil? - - # Verify topic exists - topic = Topic.find_by(id: topic_id) - return { error: "Topic not found" } if topic.nil? - - begin - post_creator = - PostCreator.new( - user, - raw: raw, - topic_id: topic_id, - reply_to_post_number: reply_to_post_number, - skip_validations: true, - guardian: Guardian.new(Discourse.system_user), - ) - - post = post_creator.create - - if post_creator.errors.present? - return { error: post_creator.errors.full_messages.join(", ") } - end - - { - success: true, - post_id: post.id, - post_number: post.post_number, - cooked: post.cooked, - } - rescue => e - { error: "Failed to create post: #{e.message}" } - end - end - end, - ) - - mini_racer_context.attach( - "_discourse_search", - ->(params) do - in_attached_function do - search_params = params.symbolize_keys - if search_params.delete(:with_private) - search_params[:current_user] = Discourse.system_user - end - search_params[:result_style] = :detailed - results = DiscourseAi::Utils::Search.perform_search(**search_params) - recursive_as_json(results) - end - end, - ) - - mini_racer_context.attach( - "_discourse_get_persona", - ->(persona_name) do - in_attached_function do - persona = AiPersona.find_by(name: persona_name) - - return { error: "Persona not found" } if persona.nil? - - # Return a subset of relevant persona attributes - { - persona: - persona.attributes.slice( - "id", - "name", - "description", - "enabled", - "system_prompt", - "temperature", - "top_p", - "vision_enabled", - "tools", - "max_context_posts", - "allow_chat_channel_mentions", - "allow_chat_direct_messages", - "allow_topic_mentions", - "allow_personal_messages", - ), - } - end - end, - ) - - mini_racer_context.attach( - "_discourse_update_persona", - ->(persona_id_or_name, updates) do - in_attached_function do - # Find persona by ID or name - persona = nil - if persona_id_or_name.is_a?(Integer) || - persona_id_or_name.to_i.to_s == persona_id_or_name - persona = AiPersona.find_by(id: persona_id_or_name.to_i) - else - persona = AiPersona.find_by(name: persona_id_or_name) - end - - return { error: "Persona not found" } if persona.nil? - - allowed_updates = {} - - if updates["system_prompt"].present? - allowed_updates[:system_prompt] = updates["system_prompt"] - end - - if updates["temperature"].is_a?(Numeric) - allowed_updates[:temperature] = updates["temperature"] - end - - allowed_updates[:top_p] = updates["top_p"] if updates["top_p"].is_a?(Numeric) - - if updates["description"].present? - allowed_updates[:description] = updates["description"] - end - - allowed_updates[:enabled] = updates["enabled"] if updates["enabled"].is_a?( - TrueClass, - ) || updates["enabled"].is_a?(FalseClass) - - if persona.update(allowed_updates) - return( - { - success: true, - persona: - persona.attributes.slice( - "id", - "name", - "description", - "enabled", - "system_prompt", - "temperature", - "top_p", - ), - } - ) - else - return { error: persona.errors.full_messages.join(", ") } - end - end - end, - ) - end - - def attach_upload(mini_racer_context) - mini_racer_context.attach( - "_upload_get_base64", - ->(upload_id_or_url, max_pixels) do - in_attached_function do - return nil if upload_id_or_url.blank? - - upload = nil - - # Handle both upload ID and short URL - if upload_id_or_url.to_s.start_with?("upload://") - # Handle short URL format - sha1 = Upload.sha1_from_short_url(upload_id_or_url) - return nil if sha1.blank? - upload = Upload.find_by(sha1: sha1) - else - # Handle numeric ID - upload_id = upload_id_or_url.to_i - return nil if upload_id <= 0 - upload = Upload.find_by(id: upload_id) - end - - return nil if upload.nil? - - max_pixels = max_pixels&.to_i - max_pixels = nil if max_pixels && max_pixels <= 0 - - encoded_uploads = - DiscourseAi::Completions::UploadEncoder.encode( - upload_ids: [upload.id], - max_pixels: max_pixels || 10_000_000, # Default to 10M pixels if not specified - ) - - encoded_uploads.first&.dig(:base64) - end - end, - ) - mini_racer_context.attach( - "_upload_get_url", - ->(short_url) do - in_attached_function do - return nil if short_url.blank? - - sha1 = Upload.sha1_from_short_url(short_url) - return nil if sha1.blank? - - upload = Upload.find_by(sha1: sha1) - return nil if upload.nil? - # TODO we may need to introduce an API to unsecure, secure uploads - return nil if upload.secure? - - GlobalPath.full_cdn_url(upload.url) - end - end, - ) - mini_racer_context.attach( - "_upload_create", - ->(filename, base_64_content) do - begin - in_attached_function do - # protect against misuse - filename = File.basename(filename) - - Tempfile.create(filename) do |file| - file.binmode - file.write(Base64.decode64(base_64_content)) - file.rewind - - upload = - UploadCreator.new( - file, - filename, - for_private_message: @context.private_message, - ).create_for(@bot_user.id) - - { id: upload.id, short_url: upload.short_url, url: upload.url } - end - end - end - end, - ) - end - - def attach_http(mini_racer_context) - mini_racer_context.attach( - "_http_get", - ->(url, options) do - begin - @http_requests_made += 1 - if @http_requests_made > MAX_HTTP_REQUESTS - raise TooManyRequestsError.new("Tool made too many HTTP requests") - end - - in_attached_function do - headers = (options && options["headers"]) || {} - base64_encode = options && options["base64Encode"] - - result = {} - DiscourseAi::Personas::Tools::Tool.send_http_request( - url, - headers: headers, - ) do |response| - if base64_encode - result[:body] = Base64.strict_encode64(response.body) - else - result[:body] = response.body - end - result[:status] = response.code.to_i - end - - result - end - end - end, - ) - - %i[post put patch delete].each do |method| - mini_racer_context.attach( - "_http_#{method}", - ->(url, options) do - begin - @http_requests_made += 1 - if @http_requests_made > MAX_HTTP_REQUESTS - raise TooManyRequestsError.new("Tool made too many HTTP requests") - end - - in_attached_function do - headers = (options && options["headers"]) || {} - body = options && options["body"] - base64_encode = options && options["base64Encode"] - - result = {} - DiscourseAi::Personas::Tools::Tool.send_http_request( - url, - method: method, - headers: headers, - body: body, - ) do |response| - if base64_encode - result[:body] = Base64.strict_encode64(response.body) - else - result[:body] = response.body - end - result[:status] = response.code.to_i - end - - result - rescue => e - if Rails.env.development? - p url - p options - p e - puts e.backtrace - end - raise e - end - end - end, - ) - end - end - - def in_attached_function - self.running_attached_function = true - yield - ensure - self.running_attached_function = false - end - - def recursive_as_json(obj) - case obj - when Array - obj.map { |item| recursive_as_json(item) } - when Hash - obj.transform_values { |value| recursive_as_json(value) } - when ActiveModel::Serializer, ActiveModel::ArraySerializer - recursive_as_json(obj.as_json) - when ActiveRecord::Base - recursive_as_json(obj.as_json) - else - # Handle objects that respond to as_json but aren't handled above - if obj.respond_to?(:as_json) - result = obj.as_json - if result.equal?(obj) - # If as_json returned the same object, return it to avoid infinite recursion - result - else - recursive_as_json(result) - end - else - # Primitive values like strings, numbers, booleans, nil - obj - end - end - end - end - end -end diff --git a/lib/personas/tools/create_artifact.rb b/lib/personas/tools/create_artifact.rb deleted file mode 100644 index cd7c517b..00000000 --- a/lib/personas/tools/create_artifact.rb +++ /dev/null @@ -1,378 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class CreateArtifact < Tool - def self.name - "create_artifact" - end - - def self.specification_description - <<~DESC - A detailed description of the web artifact you want to create. Your specification should include: - - 1. Purpose and functionality - 2. Visual design requirements - 3. Interactive elements and behavior - 4. Data handling (if applicable) - 5. Specific requirements or constraints - 6. DO NOT include full source code of the artifact, just very clear requirements - - Good specification examples: - - Example: (Calculator): - "Create a modern calculator with a dark theme. It should: - - Have a large display area showing current and previous calculations - - Include buttons for numbers 0-9, basic operations (+,-,*,/), and clear - - Use a grid layout with subtle hover effects on buttons - - Show button press animations - - Keep calculation history visible above current input - - Use a monospace font for numbers - - Support keyboard input for numbers and operations" - - Poor specification example: - "Make a website that looks nice and does cool stuff" - (Too vague, lacks specific requirements and functionality details) - - Tips for good specifications: - - Be specific about layout and design preferences - - Describe all interactive elements and their behavior - - Include any specific visual effects or animations - - Mention responsive design requirements if needed - - List any specific libraries or frameworks to use/avoid - - Describe error states and edge cases - - Include accessibility requirements - - Include code snippets to help ground the specification - DESC - end - - def self.signature - { - name: "create_artifact", - description: "Creates a web artifact based on a specification", - parameters: [ - { - name: "name", - description: "A name for the artifact (max 255 chars)", - type: "string", - required: true, - }, - { - name: "specification", - type: "string", - description: specification_description, - required: true, - }, - { - name: "requires_storage", - description: - "Does the artifact require storage for data? (e.g., user input, settings)", - type: "boolean", - required: true, - }, - ], - } - end - - def self.accepted_options - [option(:creator_llm, type: :llm)] - end - - def self.allow_partial_tool_calls? - true - end - - def partial_invoke - if parameters[:specification].present? - in_progress(specification: parameters[:specification]) - end - end - - def in_progress(specification:, source: nil) - source = (<<~HTML) if source.present? - ### Source - - ```` - #{source} - ```` - HTML - - self.custom_raw = <<~HTML - <details> - <summary>Thinking...</summary> - - - ### Specification - ```` - #{specification} - ```` - - #{source} - - </details> - HTML - end - - def invoke - post = Post.find_by(id: context.post_id) - return error_response("No post context found") unless post - - partial_response = +"" - artifact_code = - generate_artifact_code(post: post, user: post.user) do |partial| - partial_response << partial - in_progress(specification: parameters[:specification], source: partial_response) - yield nil, true - end - return error_response(artifact_code[:error]) if artifact_code[:error] - - artifact = create_artifact(post, artifact_code) - - if artifact.save - update_custom_html(artifact) - success_response(artifact) - else - self.custom_raw = self.custom_raw + "\n\n###Error creating artifact..." - error_response(artifact.errors.full_messages.join(", ")) - end - end - - def chain_next_response? - false - end - - def description_args - { name: parameters[:name], specification: parameters[:specification] } - end - - private - - def generate_artifact_code(post:, user:) - prompt = build_artifact_prompt(post: post) - response = +"" - - llm = - ( - options[:creator_llm].present? && - LlmModel.find_by(id: options[:creator_llm].to_i)&.to_llm - ) || self.llm - - llm.generate( - prompt, - user: user, - feature_name: "create_artifact", - cancel_manager: context.cancel_manager, - ) do |partial_response| - response << partial_response - yield partial_response - end - - sections = parse_sections(response) - - if valid_sections?(sections) - html, css, js = sections - { html: html, css: css, js: js } - else - { error: "Failed to generate valid artifact code", response: response } - end - end - - def build_artifact_prompt(post:) - DiscourseAi::Completions::Prompt.new( - artifact_system_prompt, - messages: [{ type: :user, content: parameters[:specification] }], - post_id: post.id, - topic_id: post.topic_id, - ) - end - - def parse_sections(response) - sections = { html: nil, css: nil, javascript: nil } - current_section = nil - lines = [] - - response.each_line do |line| - case line - when /^\[(HTML|CSS|JavaScript)\]$/ - current_section = line.match(/^\[(.+)\]$/)[1].downcase.to_sym - lines = [] - when %r{^\[/(HTML|CSS|JavaScript)\]$} - sections[current_section] = lines.join if current_section - current_section = nil - lines = [] - else - lines << line if current_section - end - end - - [sections[:html].to_s.strip, sections[:css].to_s.strip, sections[:javascript].to_s.strip] - end - - def valid_sections?(sections) - return false if sections.empty? - - # Basic validation of sections - has_html = sections[0].include?("<") && sections[0].include?(">") - has_css = sections[1].include?("{") && sections[1].include?("}") - has_js = sections[2].present? - - has_html || has_css || has_js - end - - def create_artifact(post, code) - AiArtifact.new( - user_id: bot_user.id, - post_id: post.id, - name: parameters[:name].to_s[0...255], - html: code[:html], - css: code[:css], - js: code[:js], - metadata: { - specification: parameters[:specification], - requires_storage: !!parameters[:requires_storage], - }, - ) - end - - def artifact_system_prompt - <<~PROMPT - You are a web development expert creating HTML, CSS, and JavaScript code. - Follow these instructions precisely: - - 1. Provide complete source code for all three required sections: HTML, CSS, and JavaScript - 2. Use exact section tags: [HTML]/[/HTML], [CSS]/[/CSS], [JavaScript]/[/JavaScript] - 3. Format requirements: - - HTML: No <html>, <head>, or <body> tags - - CSS: Valid CSS rules - - JavaScript: Clean, working code - 4. NEVER USE SHORTCUTS - generate complete code for each section. No placeholders. - 5. If you need to source ANY 3rd party libraries, use the following CDNs: - #{AiArtifact::ALLOWED_CDN_SOURCES.join("\n")} - - 6. When sourcing libraries, include them in the [HTML] section, for example: <script src="https://cdn.jsdelivr.net/...."></script> - - Required response format: - - [HTML] - <div id="app"><!-- Your complete HTML here --></div> - [/HTML] - - [CSS] - #app { /* Your complete CSS here */ } - [/CSS] - - [JavaScript] - // Your complete JavaScript here - [/JavaScript] - - Important: - - All three sections are required - - Sections must use exact tags shown above - - Focus on simplicity and reliability - - Include basic error handling - - Follow accessibility guidelines - - No explanatory text, only code - - #{storage_api} - PROMPT - end - - def storage_api - return if !parameters[:requires_storage] - self.class.storage_api - end - - def self.storage_api - <<~API - ## Storage API - - Your artifact has access to a persistent key-value storage system via `window.discourseArtifact`: - - ### Methods Available: - - **get(key)** - - Parameters: key (string) - The key to retrieve - - Returns: Promise<string|null> - The stored value or null if not found - - Example: `const value = await window.discourseArtifact.get('user_name');` - - **set(key, value, options)** - - Parameters: - - key (string) - The key to store (max 50 characters) - - value (string) - The value to store (max 5000 characters) - - options (object, optional) - { public: boolean } - Whether other users can read this value - - Returns: Promise<object> - The created/updated key-value record - - Example: `await window.discourseArtifact.set('score', '100', { public: true });` - - **delete(key)** - - Parameters: key (string) - The key to delete - - Returns: Promise<boolean> - true if successful - - Example: `await window.discourseArtifact.delete('temp_data');` - - **index(filter)** - - Parameters: filter (object, optional) - Filtering options: - - key (string) - Filter by specific key - - all_users (boolean) - Include other users' public values - - keys_only (boolean) - Return only keys, not values - - page (number) - Page number for pagination - - per_page (number) - Items per page (max 100, default 100) - - Returns: Promise<object> - { key_values: Array(key, value, user(username, name, avatar_template)), has_more: boolean, total_count: number } - - Example: `const result = await window.discourseArtifact.index({ keys_only: true });` - - - avatar_template: string - URL template for user avatars, MUST replace {size} with desired size in pixels (eg: 22) - - ### User info: - - To get current user info: - const initData = await window.discourseArtifactReady; - initData.username; // current username - initData.name; // current user's name - initData.user_id; // current user ID - - ### Storage Rules: - - Each user can store up to 100 keys per artifact - - Keys are scoped to the current user and artifact - - Private values are only accessible to the user who created them - - Public values can be read by anyone who can view the artifact - - All operations are asynchronous and return Promises - ``` - API - end - - def update_custom_html(artifact) - html_preview = <<~MD - [details="View Source"] - ### HTML - ```html - #{artifact.html} - ``` - - ### CSS - ```css - #{artifact.css} - ``` - - ### JavaScript - ```javascript - #{artifact.js} - ``` - [/details] - - ### Preview - <div class="ai-artifact" data-ai-artifact-id="#{artifact.id}"></div> - MD - - self.custom_raw = html_preview - end - - def success_response(artifact) - { status: "success", artifact_id: artifact.id, message: "Artifact created successfully." } - end - - def error_response(message) - { status: "error", error: message } - end - end - end - end -end diff --git a/lib/personas/tools/create_image.rb b/lib/personas/tools/create_image.rb deleted file mode 100644 index 8e2971fa..00000000 --- a/lib/personas/tools/create_image.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class CreateImage < Tool - def self.signature - { - name: name, - description: "Renders images from supplied descriptions", - parameters: [ - { - name: "prompts", - description: - "The prompts used to generate or create or draw the image (5000 chars or less, be creative) up to 4 prompts, usually only supply a single prompt", - type: "array", - item_type: "string", - required: true, - }, - ], - } - end - - def self.name - "create_image" - end - - def prompts - parameters[:prompts] - end - - def chain_next_response? - !!@error - end - - def invoke - # max 4 prompts - max_prompts = prompts.take(4) - progress = prompts.first - - yield(progress) - - results = nil - - begin - results = - DiscourseAi::Inference::OpenAiImageGenerator.create_uploads!( - max_prompts, - model: "gpt-image-1", - user_id: bot_user.id, - cancel_manager: context.cancel_manager, - ) - rescue => e - @error = e - return { prompts: max_prompts, error: e.message } - end - - if results.blank? - @error = true - return { prompts: max_prompts, error: "Something went wrong, could not generate image" } - end - - self.custom_raw = <<~RAW - - [grid] - #{ - results - .map { |item| "![#{item[:prompt].gsub(/\|\'\"/, "")}](#{item[:upload].short_url})" } - .join(" ") - } - [/grid] - RAW - - { - prompts: results.map { |item| { prompt: item[:prompt], url: item[:upload].short_url } }, - } - end - - protected - - def description_args - { prompt: prompts.first } - end - end - end - end -end diff --git a/lib/personas/tools/custom.rb b/lib/personas/tools/custom.rb deleted file mode 100644 index 6361402c..00000000 --- a/lib/personas/tools/custom.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Custom < Tool - def self.class_instance(tool_id) - klass = Class.new(self) - klass.tool_id = tool_id - klass - end - - def self.custom? - true - end - - def self.tool_id - @tool_id - end - - def self.tool_id=(tool_id) - @tool_id = tool_id - end - - def self.signature - AiTool.find(tool_id).signature - end - - # Backwards compatibility: if tool_name is not set (existing custom tools), use name - def self.name - name, tool_name = AiTool.where(id: tool_id).pluck(:name, :tool_name).first - tool_name.presence || name - end - - def self.has_custom_context? - # note on safety, this can be cached safely, we bump the whole persona cache when an ai tool is saved - # which will expire this class - return @has_custom_context if defined?(@has_custom_context) - - @has_custom_context = false - ai_tool = AiTool.find_by(id: tool_id) - if ai_tool.script.include?("customContext") - runner = ai_tool.runner({}, llm: nil, bot_user: nil, context: nil) - @has_custom_context = runner.has_custom_context? - end - - @has_custom_context - end - - def self.inject_prompt(prompt:, context:, persona:) - if has_custom_context? - ai_tool = AiTool.find_by(id: tool_id) - if ai_tool - runner = ai_tool.runner({}, llm: nil, bot_user: nil, context: context) - custom_context = runner.custom_context - if custom_context.present? - last_message = prompt.messages.last - last_message[:content] = "#{custom_context}\n\n#{last_message[:content]}" - end - end - end - end - - def initialize(*args, **kwargs) - @chain_next_response = true - super(*args, **kwargs) - end - - def invoke(&blk) - callback = - proc do |raw| - if blk - self.custom_raw = raw - @chain_next_response = false - blk.call(raw, true) - end - end - result = runner.invoke(progress_callback: callback) - if runner.custom_raw - self.custom_raw = runner.custom_raw - @chain_next_response = false - end - result - end - - def runner - @runner ||= ai_tool.runner(parameters, llm: llm, bot_user: bot_user, context: context) - end - - def ai_tool - @ai_tool ||= AiTool.find(self.class.tool_id) - end - - def summary - ai_tool.summary - end - - def details - runner.details - end - - def chain_next_response? - !!@chain_next_response - end - - def help - # I do not think this is called, but lets make sure - raise "Not implemented" - end - end - end - end -end diff --git a/lib/personas/tools/dall_e.rb b/lib/personas/tools/dall_e.rb deleted file mode 100644 index 1daa7ee1..00000000 --- a/lib/personas/tools/dall_e.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class DallE < Tool - def self.signature - { - name: name, - description: "Renders images from supplied descriptions", - parameters: [ - { - name: "prompts", - description: - "The prompts used to generate or create or draw the image (5000 chars or less, be creative) up to 4 prompts", - type: "array", - item_type: "string", - required: true, - }, - { - name: "aspect_ratio", - description: "The aspect ratio (optional, square by default)", - type: "string", - required: false, - enum: %w[tall square wide], - }, - ], - } - end - - def self.name - "dall_e" - end - - def prompts - parameters[:prompts] - end - - def aspect_ratio - parameters[:aspect_ratio] - end - - def chain_next_response? - false - end - - def invoke - # max 4 prompts - max_prompts = prompts.take(4) - progress = prompts.first - - yield(progress) - - results = nil - - size = "1024x1024" - if aspect_ratio == "tall" - size = "1024x1792" - elsif aspect_ratio == "wide" - size = "1792x1024" - end - - results = - DiscourseAi::Inference::OpenAiImageGenerator.create_uploads!( - max_prompts, - model: "dall-e-3", - size: size, - user_id: bot_user.id, - ) - - if results.blank? - return { prompts: max_prompts, error: "Something went wrong, could not generate image" } - end - - self.custom_raw = <<~RAW - - [grid] - #{ - results - .map { |item| "![#{item[:prompt].gsub(/\|\'\"/, "")}](#{item[:upload].short_url})" } - .join(" ") - } - [/grid] - RAW - - { prompts: results.map { |item| item[:prompt] } } - end - - protected - - def description_args - { prompt: prompts.first } - end - end - end - end -end diff --git a/lib/personas/tools/db_schema.rb b/lib/personas/tools/db_schema.rb deleted file mode 100644 index b0a6f296..00000000 --- a/lib/personas/tools/db_schema.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class DbSchema < Tool - def self.signature - { - name: name, - description: "Will load schema information for specific tables in the database", - parameters: [ - { - name: "tables", - description: - "list of tables to load schema information for, comma separated list eg: (users,posts))", - type: "string", - required: true, - }, - ], - } - end - - def self.name - "schema" - end - - def tables - parameters[:tables] - end - - def invoke - tables_arr = tables.split(",").map(&:strip) - - table_info = {} - DB - .query(<<~SQL, tables_arr) - select table_name, column_name, data_type from information_schema.columns - where table_schema = 'public' - and table_name in (?) - order by table_name - SQL - .each do |row| - (table_info[row.table_name] ||= []) << "#{row.column_name} #{row.data_type}" - end - - schema_info = - table_info - .map { |table_name, columns| "#{table_name}(#{columns.join(",")})" } - .join("\n") - - { schema_info: schema_info, tables: tables } - end - - protected - - def description_args - { tables: tables } - end - end - end - end -end diff --git a/lib/personas/tools/discourse_meta_search.rb b/lib/personas/tools/discourse_meta_search.rb deleted file mode 100644 index 5fdfb76e..00000000 --- a/lib/personas/tools/discourse_meta_search.rb +++ /dev/null @@ -1,192 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class DiscourseMetaSearch < Tool - class << self - def signature - { - name: name, - description: - "Will search topics in the current discourse instance, when rendering always prefer to link to the topics you find", - parameters: [ - { - name: "search_query", - description: - "Specific keywords to search for, space separated (correct bad spelling, remove connector words)", - type: "string", - }, - { - name: "user", - description: - "Filter search results to this username (only include if user explicitly asks to filter by user)", - type: "string", - }, - { - name: "order", - description: "search result order", - type: "string", - enum: %w[latest latest_topic oldest views likes], - }, - { - name: "max_posts", - description: - "maximum number of posts on the topics (topics where lots of people posted)", - type: "integer", - }, - { - name: "tags", - description: - "list of tags to search for. Use + to join with OR, use , to join with AND", - type: "string", - }, - { name: "category", description: "category name to filter to", type: "string" }, - { - name: "before", - description: "only topics created before a specific date YYYY-MM-DD", - type: "string", - }, - { - name: "after", - description: "only topics created after a specific date YYYY-MM-DD", - type: "string", - }, - { - name: "status", - description: "search for topics in a particular state", - type: "string", - enum: %w[open closed archived noreplies single_user], - }, - ], - } - end - - def name - "search_meta_discourse" - end - - def custom_system_message - <<~TEXT - You were trained on OLD data, lean on search to get up to date information - Discourse search joins all terms with AND. Reduce and simplify terms to find more results. - TEXT - end - end - - def search_args - parameters.slice(:category, :user, :order, :max_posts, :tags, :before, :after, :status) - end - - def invoke - search_string = - search_args.reduce(+parameters[:search_query].to_s) do |memo, (key, value)| - return memo if value.blank? - memo << " " << "#{key}:#{value}" - end - - @last_query = search_string - - yield(I18n.t("discourse_ai.ai_bot.searching", query: search_string)) - - if options[:base_query].present? - search_string = "#{search_string} #{options[:base_query]}" - end - - url = "https://meta.discourse.org/search.json?q=#{CGI.escape(search_string)}" - - json = JSON.parse(Net::HTTP.get(URI(url))) - - # let's be frugal with tokens, 50 results is too much and stuff gets cut off - max_results = calculate_max_results(llm) - results_limit = parameters[:limit] || max_results - results_limit = max_results if parameters[:limit].to_i > max_results - - posts = json["posts"] || [] - posts = posts[0..results_limit.to_i - 1] - - @last_num_results = posts.length - - if posts.blank? - { args: parameters, rows: [], instruction: "nothing was found, expand your search" } - else - categories = - if categories_json = json.dig("grouped_search_result", "extra", "categories") - categories_json.map { |c| [c["id"], c] }.to_h - else - self.class.categories - end - - topics = (json["topics"]).map { |t| [t["id"], t] }.to_h - - format_results(posts, args: parameters) do |post| - topic = topics[post["topic_id"]] - - category = categories[topic["category_id"]] - category_names = +"" - # TODO @nbianca: this is broken now cause we are not getting child categories - # to avoid erroring out we simply skip - # sideloading from search would probably be easier - if category - if category["parent_category_id"] - category_names << categories[category["parent_category_id"]]["name"] << " > " - end - category_names << category["name"] - end - row = { - title: topic["title"], - url: "https://meta.discourse.org/t/-/#{post["topic_id"]}/#{post["post_number"]}", - username: post["username"], - excerpt: post["blurb"], - created: post["created_at"], - category: category_names, - likes: post["like_count"], - tags: topic["tags"].join(", "), - } - - row - end - end - end - - protected - - def self.categories - return @categories if defined?(@categories) - - url = "https://meta.discourse.org/site.json" - json = JSON.parse(Net::HTTP.get(URI(url))) - @categories = - json["categories"] - .map do |c| - [c["id"], { "name" => c["name"], "parent_category_id" => c["parent_category_id"] }] - end - .to_h - end - - def description_args - { - count: @last_num_results || 0, - query: @last_query || "", - url: "https://meta.discourse.org/search?q=#{CGI.escape(@last_query || "")}", - } - end - - private - - def calculate_max_results(llm) - max_results = options[:max_results].to_i - return [max_results, 100].min if max_results > 0 - - if llm.max_prompt_tokens > 30_000 - 60 - elsif llm.max_prompt_tokens > 10_000 - 40 - else - 20 - end - end - end - end - end -end diff --git a/lib/personas/tools/edit_image.rb b/lib/personas/tools/edit_image.rb deleted file mode 100644 index b9e3249a..00000000 --- a/lib/personas/tools/edit_image.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class EditImage < Tool - def self.signature - { - name: name, - description: "Renders images from supplied descriptions", - parameters: [ - { - name: "prompt", - description: - "instructions for the image to be edited (5000 chars or less, be creative)", - type: "string", - required: true, - }, - { - name: "image_urls", - description: - "The images to provides as context for the edit (minimum 1, maximum 10), use the short url eg: upload://qUm0DGR49PAZshIi7HxMd3cAlzn.png", - type: "array", - item_type: "string", - required: true, - }, - ], - } - end - - def self.name - "edit_image" - end - - def prompt - parameters[:prompt] - end - - def chain_next_response? - !!@error - end - - def image_urls - parameters[:image_urls] - end - - def invoke - yield(prompt) - - return { prompt: prompt, error: "No valid images provided" } if image_urls.blank? - - sha1s = image_urls.map { |url| Upload.sha1_from_short_url(url) }.compact - uploads = Upload.where(sha1: sha1s).order(created_at: :asc).limit(10).to_a - - return { prompt: prompt, error: "No valid images provided" } if uploads.blank? - - begin - result = - DiscourseAi::Inference::OpenAiImageGenerator.create_edited_upload!( - uploads, - prompt, - user_id: bot_user.id, - cancel_manager: context.cancel_manager, - ) - rescue => e - @error = e - return { prompt: prompt, error: e.message } - end - - if result.blank? - @error = true - return { prompt: prompt, error: "Something went wrong, could not generate image" } - end - - self.custom_raw = "![#{result[:prompt].gsub(/\|\'\"/, "")}](#{result[:upload].short_url})" - - { prompt: result[:prompt], url: result[:upload].short_url } - end - - protected - - def description_args - { prompt: prompt } - end - end - end - end -end diff --git a/lib/personas/tools/github_file_content.rb b/lib/personas/tools/github_file_content.rb deleted file mode 100644 index aef00c1e..00000000 --- a/lib/personas/tools/github_file_content.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true -module DiscourseAi - module Personas - module Tools - class GithubFileContent < Tool - def self.signature - { - name: name, - description: "Retrieves the content of specified GitHub files", - parameters: [ - { - name: "repo_name", - description: "The name of the GitHub repository (e.g., 'discourse/discourse')", - type: "string", - required: true, - }, - { - name: "file_paths", - description: "The paths of the files to retrieve within the repository", - type: "array", - item_type: "string", - required: true, - }, - { - name: "branch", - description: - "The branch or commit SHA to retrieve the files from (default: 'main')", - type: "string", - required: false, - }, - ], - } - end - - def self.name - "github_file_content" - end - - def repo_name - parameters[:repo_name] - end - - def file_paths - parameters[:file_paths] - end - - def branch - parameters[:branch] || "main" - end - - def description_args - { repo_name: repo_name, file_paths: file_paths.join(", "), branch: branch } - end - - def invoke - owner, repo = repo_name.split("/") - file_contents = {} - missing_files = [] - - file_paths.each do |file_path| - api_url = - "https://api.github.com/repos/#{owner}/#{repo}/contents/#{file_path}?ref=#{branch}" - - response_code = "-1 unknown" - body = nil - - send_http_request( - api_url, - headers: { - "Accept" => "application/vnd.github.v3+json", - }, - authenticate_github: true, - ) do |response| - response_code = response.code - body = read_response_body(response) - end - - if response_code == "200" - file_data = JSON.parse(body) - content = Base64.decode64(file_data["content"]) - file_contents[file_path] = content - else - missing_files << file_path - end - end - - result = {} - unless file_contents.empty? - blob = - file_contents.map { |path, content| "File Path: #{path}:\n#{content}" }.join("\n") - truncated_blob = truncate(blob, max_length: 20_000, percent_length: 0.3, llm: llm) - result[:file_contents] = truncated_blob - end - - result[:missing_files] = missing_files unless missing_files.empty? - - result.empty? ? { error: "No files found or retrieved." } : result - end - end - end - end -end diff --git a/lib/personas/tools/github_pull_request_diff.rb b/lib/personas/tools/github_pull_request_diff.rb deleted file mode 100644 index afbe51f9..00000000 --- a/lib/personas/tools/github_pull_request_diff.rb +++ /dev/null @@ -1,155 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class GithubPullRequestDiff < Tool - LARGE_OBJECT_THRESHOLD = 30_000 - - def self.signature - { - name: name, - description: "Retrieves the diff for a GitHub pull request", - parameters: [ - { - name: "repo", - description: "The repository name in the format 'owner/repo'", - type: "string", - required: true, - }, - { - name: "pull_id", - description: "The ID of the pull request", - type: "integer", - required: true, - }, - ], - } - end - - def self.name - "github_pull_request_diff" - end - - def repo - parameters[:repo] - end - - def pull_id - parameters[:pull_id] - end - - def url - @url - end - - def invoke - api_url = "https://api.github.com/repos/#{repo}/pulls/#{pull_id}" - @url = "https://github.com/#{repo}/pull/#{pull_id}" - - pr_info = nil - diff_body = nil - response_code = "unknown error" - - send_http_request( - api_url, - headers: { - "Accept" => "application/json", - }, - authenticate_github: true, - ) do |response| - response_code = response.code - pr_info = JSON.parse(read_response_body(response)) if response_code == "200" - end - - if response_code == "200" - send_http_request( - api_url, - headers: { - "Accept" => "application/vnd.github.v3.diff", - }, - authenticate_github: true, - ) do |response| - response_code = response.code - diff_body = read_response_body(response) - end - end - - if response_code == "200" && pr_info && diff_body - diff = diff_body - diff = self.class.sort_and_shorten_diff(diff) - diff = truncate(diff, max_length: 20_000, percent_length: 0.3, llm: llm) - - source_repo = pr_info.dig("head", "repo", "full_name") - source_branch = pr_info.dig("head", "ref") - source_sha = pr_info.dig("head", "sha") - - { - diff: diff, - pr_info: { - title: pr_info["title"], - state: pr_info["state"], - source: { - repo: source_repo, - branch: source_branch, - sha: source_sha, - url: "https://github.com/#{source_repo}/tree/#{source_branch}", - }, - target: { - repo: pr_info["base"]["repo"]["full_name"], - branch: pr_info["base"]["ref"], - }, - author: pr_info["user"]["login"], - created_at: pr_info["created_at"], - updated_at: pr_info["updated_at"], - }, - } - else - { error: "Failed to retrieve the PR information. Status code: #{response_code}" } - end - end - - def description_args - { repo: repo, pull_id: pull_id, url: url } - end - - def self.sort_and_shorten_diff(diff, threshold: LARGE_OBJECT_THRESHOLD) - # This regex matches the start of a new file in the diff, - # capturing the file paths for later use. - file_start_regex = /^diff --git.*/ - - prev_start = -1 - prev_match = nil - - split = [] - - diff.scan(file_start_regex) do |match| - match_start = $~.offset(0)[0] # Get the start position of this match - - if prev_start != -1 - full_diff = diff[prev_start...match_start] - split << [prev_match, full_diff] - end - - prev_match = match - prev_start = match_start - end - - split << [prev_match, diff[prev_start..-1]] if prev_match - - split.sort! { |x, y| x[1].length <=> y[1].length } - - split - .map do |x, y| - if y.length < threshold - y - else - "#{x}\nRedacted, Larger than #{threshold} chars" - end - end - .join("\n") - end - end - end - end -end diff --git a/lib/personas/tools/github_search_code.rb b/lib/personas/tools/github_search_code.rb deleted file mode 100644 index 3bb93d02..00000000 --- a/lib/personas/tools/github_search_code.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class GithubSearchCode < Tool - def self.signature - { - name: name, - description: "Searches for code in a GitHub repository", - parameters: [ - { - name: "repo", - description: "The repository name in the format 'owner/repo'", - type: "string", - required: true, - }, - { - name: "query", - description: "The search query (e.g., a function name, variable, or code snippet)", - type: "string", - required: true, - }, - ], - } - end - - def self.name - "github_search_code" - end - - def repo - parameters[:repo] - end - - def query - parameters[:query] - end - - def description_args - { repo: repo, query: query } - end - - def invoke - api_url = "https://api.github.com/search/code?q=#{query}+repo:#{repo}" - - response_code = "unknown error" - search_data = nil - - send_http_request( - api_url, - headers: { - "Accept" => "application/vnd.github.v3.text-match+json", - }, - authenticate_github: true, - ) do |response| - response_code = response.code - if response_code == "200" - begin - search_data = JSON.parse(read_response_body(response)) - rescue JSON::ParserError - response_code = "500 - JSON parse error" - end - end - end - - if response_code == "200" - results = - search_data["items"] - .map { |item| "#{item["path"]}:\n#{item["text_matches"][0]["fragment"]}" } - .join("\n---\n") - - results = truncate(results, max_length: 20_000, percent_length: 0.3, llm: llm) - { search_results: results } - else - { error: "Failed to perform code search. Status code: #{response_code}" } - end - end - end - end - end -end diff --git a/lib/personas/tools/github_search_files.rb b/lib/personas/tools/github_search_files.rb deleted file mode 100644 index c97cbd7c..00000000 --- a/lib/personas/tools/github_search_files.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class GithubSearchFiles < Tool - def self.signature - { - name: name, - description: - "Searches for files in a GitHub repository containing specific keywords in their paths or names", - parameters: [ - { - name: "repo", - description: "The repository name in the format 'owner/repo'", - type: "string", - required: true, - }, - { - name: "keywords", - description: "An array of keywords to match in file paths or names", - type: "array", - item_type: "string", - required: true, - }, - { - name: "branch", - description: - "The branch or commit SHA to search within (default: repository's default branch)", - type: "string", - required: false, - }, - ], - } - end - - def self.name - "github_search_files" - end - - def repo - parameters[:repo] - end - - def keywords - parameters[:keywords] - end - - def branch - parameters[:branch] - end - - def description_args - { repo: repo, keywords: keywords.join(", "), branch: @branch_name } - end - - def invoke - # Fetch the default branch if no branch is specified - branch_name = branch || fetch_default_branch(repo) - @branch_name = branch_name - - api_url = "https://api.github.com/repos/#{repo}/git/trees/#{branch_name}?recursive=1" - - response_code = "unknown error" - tree_data = nil - - send_http_request( - api_url, - headers: { - "Accept" => "application/vnd.github.v3+json", - }, - authenticate_github: true, - ) do |response| - response_code = response.code - if response_code == "200" - begin - tree_data = JSON.parse(read_response_body(response)) - rescue JSON::ParserError - response_code = "500 - JSON parse error" - end - end - end - - if response_code == "200" - matching_files = - tree_data["tree"] - .select do |item| - item["type"] == "blob" && - keywords.any? { |keyword| item["path"].include?(keyword) } - end - .map { |item| item["path"] } - - { matching_files: matching_files, branch: branch_name } - else - { error: "Failed to perform file search. Status code: #{response_code}" } - end - end - end - end - end -end diff --git a/lib/personas/tools/google.rb b/lib/personas/tools/google.rb deleted file mode 100644 index 665fe163..00000000 --- a/lib/personas/tools/google.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Google < Tool - def self.signature - { - name: name, - description: - "Will search using Google - global internet search (supports all Google search operators)", - parameters: [ - { name: "query", description: "The search query", type: "string", required: true }, - ], - } - end - - def self.custom_system_message - "You were trained on OLD data, lean on search to get up to date information from the web" - end - - def self.name - "google" - end - - def self.accepted_options - [option(:base_query, type: :string)] - end - - def query - parameters[:query].to_s.strip - end - - def invoke - query = self.query - - yield(query) - - api_key = SiteSetting.ai_google_custom_search_api_key - cx = SiteSetting.ai_google_custom_search_cx - - query = "#{options[:base_query]} #{query}" if options[:base_query].present? - - escaped_query = CGI.escape(query) - uri = - URI( - "https://www.googleapis.com/customsearch/v1?key=#{api_key}&cx=#{cx}&q=#{escaped_query}&num=10", - ) - - body = Net::HTTP.get(uri) - - parse_search_json(body, escaped_query, llm) - end - - attr_reader :results_count - - protected - - def description_args - { - count: results_count || 0, - query: query, - url: "https://google.com/search?q=#{CGI.escape(query)}", - } - end - - private - - def minimize_field(result, field, llm, max_tokens: 100) - data = result[field] - return "" if data.blank? - - llm - .tokenizer - .truncate(data, max_tokens, strict: SiteSetting.ai_strict_token_counting) - .squish - end - - def parse_search_json(json_data, escaped_query, llm) - parsed = JSON.parse(json_data) - error_code = parsed.dig("error", "code") - if error_code == 429 - Rails.logger.warn( - "Google Custom Search is Rate Limited, no search can be performed at the moment. #{json_data[0..1000]}", - ) - return( - "Google Custom Search is Rate Limited, no search can be performed at the moment. Let the user know there is a problem." - ) - elsif error_code - Rails.logger.warn("Google Custom Search returned an error. #{json_data[0..1000]}") - return "Google Custom Search returned an error. Let the user know there is a problem." - end - - results = parsed["items"] - - @results_count = parsed.dig("searchInformation", "totalResults").to_i - - format_results(results, args: escaped_query) do |result| - { - title: minimize_field(result, "title", llm), - link: minimize_field(result, "link", llm), - snippet: minimize_field(result, "snippet", llm, max_tokens: 120), - displayLink: minimize_field(result, "displayLink", llm), - formattedUrl: minimize_field(result, "formattedUrl", llm), - } - end - end - end - end - end -end diff --git a/lib/personas/tools/image.rb b/lib/personas/tools/image.rb deleted file mode 100644 index 3ab2b705..00000000 --- a/lib/personas/tools/image.rb +++ /dev/null @@ -1,167 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Image < Tool - def self.signature - { - name: name, - description: - "Renders an image from the description (remove all connector words, keep it to 40 words or less). Despite being a text based bot you can generate images! (when user asks to draw, paint or other synonyms try this)", - parameters: [ - { - name: "prompts", - description: - "The prompts used to generate or create or draw the image (40 words or less, be creative) up to 4 prompts", - type: "array", - item_type: "string", - required: true, - }, - { - name: "seeds", - description: - "The seed used to generate the image (optional) - can be used to retain image style on amended prompts", - type: "array", - item_type: "integer", - }, - { - name: "aspect_ratio", - description: "The aspect ratio of the image (optional defaults to 1:1)", - type: "string", - required: false, - enum: %w[16:9 1:1 21:9 2:3 3:2 4:5 5:4 9:16 9:21], - }, - ], - } - end - - def self.name - "image" - end - - def initialize(*args, **kwargs) - super - @chain_next_response = false - end - - def prompts - parameters[:prompts] - end - - def aspect_ratio - parameters[:aspect_ratio] - end - - def seeds - parameters[:seeds] - end - - def chain_next_response? - @chain_next_response - end - - def invoke - # max 4 prompts - selected_prompts = prompts.take(4) - seeds = seeds.take(4) if seeds - - progress = prompts.first - yield(progress) - - results = nil - - # this ensures multisite safety since background threads - # generate the images - api_key = SiteSetting.ai_stability_api_key - engine = SiteSetting.ai_stability_engine - api_url = SiteSetting.ai_stability_api_url - - threads = [] - selected_prompts.each_with_index do |prompt, index| - seed = seeds ? seeds[index] : nil - threads << Thread.new(seed, prompt) do |inner_seed, inner_prompt| - attempts = 0 - begin - DiscourseAi::Inference::StabilityGenerator.perform!( - inner_prompt, - engine: engine, - api_key: api_key, - api_url: api_url, - image_count: 1, - seed: inner_seed, - aspect_ratio: aspect_ratio, - ) - rescue => e - attempts += 1 - retry if attempts < 3 - Rails.logger.warn("Failed to generate image for prompt #{prompt}: #{e}") - nil - end - end - end - - break if threads.all? { |t| t.join(2) } while true - - results = threads.map(&:value).compact - - if !results.present? - @chain_next_response = true - return( - { - prompts: prompts, - error: - "Something went wrong inform user you could not generate image, check Discourse logs, give up don't try anymore", - give_up: true, - } - ) - end - - uploads = [] - - results.each_with_index do |result, index| - result[:artifacts].each do |image| - Tempfile.create("v1_txt2img_#{index}.png") do |file| - file.binmode - file.write(Base64.decode64(image[:base64])) - file.rewind - uploads << { - prompt: prompts[index], - upload: - UploadCreator.new( - file, - "image.png", - for_private_message: context.private_message, - ).create_for(bot_user.id), - seed: image[:seed], - } - end - end - end - - @custom_raw = <<~RAW - - [grid] - #{ - uploads - .map { |item| "![#{item[:prompt].gsub(/\|\'\"/, "")}](#{item[:upload].short_url})" } - .join(" ") - } - [/grid] - RAW - - { - prompts: uploads.map { |item| item[:prompt] }, - seeds: uploads.map { |item| item[:seed] }, - } - end - - protected - - def description_args - { prompt: prompts.first } - end - end - end - end -end diff --git a/lib/personas/tools/javascript_evaluator.rb b/lib/personas/tools/javascript_evaluator.rb deleted file mode 100644 index 23d7c1b1..00000000 --- a/lib/personas/tools/javascript_evaluator.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -require "mini_racer" -require "json" - -module DiscourseAi - module Personas - module Tools - class JavascriptEvaluator < Tool - TIMEOUT = 500 - MAX_MEMORY = 10_000_000 - MARSHAL_STACK_DEPTH = 20 - - def self.signature - { - name: name, - description: "Evaluates JavaScript code using MiniRacer", - parameters: [ - { - name: "script", - description: "The JavaScript code to evaluate", - type: "string", - required: true, - }, - ], - } - end - - def self.name - "javascript_evaluator" - end - - def script - parameters[:script].to_s - end - - def timeout - @timeout || TIMEOUT - end - - def timeout=(value) - @timeout = value - end - - def max_memory - @max_memory || MAX_MEMORY - end - - def max_memory=(value) - @max_memory = value - end - - def invoke - context = - MiniRacer::Context.new( - timeout: timeout, - max_memory: MAX_MEMORY, - marshal_stack_depth: MARSHAL_STACK_DEPTH, - ) - - # works around llms like anthropic loving console.log - eval_script = <<~JS - let console = {}; - console.log = function(val) { - return val; - }; - - #{script} - JS - - result = context.eval(eval_script) - - # only do special handling and truncating for long strings - if result.to_s.length > 1000 - result = truncate(result.to_s, max_length: 10_000, percent_length: 0.3, llm: llm) - end - - { result: result } - rescue MiniRacer::ScriptTerminatedError => e - { error: "JavaScript execution timed out: #{e.message}" } - rescue MiniRacer::V8OutOfMemoryError => e - { error: "JavaScript execution exceeded memory limit: #{e.message}" } - rescue MiniRacer::Error => e - { error: "JavaScript execution error: #{e.message}" } - end - - def details - <<~MD - - - ``` - #{script} - ``` - - MD - end - - private - - def description_args - { script: script } - end - end - end - end -end diff --git a/lib/personas/tools/list_categories.rb b/lib/personas/tools/list_categories.rb deleted file mode 100644 index 895eaae5..00000000 --- a/lib/personas/tools/list_categories.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class ListCategories < Tool - def self.signature - { - name: name, - description: - "Will list the categories on the current discourse instance, prefer to format with # in front of the category name", - } - end - - def self.name - "categories" - end - - def invoke - columns = { - name: "Name", - slug: "Slug", - description: "Description", - posts_year: "Posts Year", - posts_month: "Posts Month", - posts_week: "Posts Week", - id: "id", - parent_category_id: "parent_category_id", - } - - rows = Category.where(read_restricted: false).limit(100).pluck(*columns.keys) - - @last_count = rows.length - - { rows: rows, column_names: columns.values } - end - - private - - def description_args - { count: @last_count || 0 } - end - end - end - end -end diff --git a/lib/personas/tools/list_tags.rb b/lib/personas/tools/list_tags.rb deleted file mode 100644 index 852c9abc..00000000 --- a/lib/personas/tools/list_tags.rb +++ /dev/null @@ -1,41 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class ListTags < Tool - def self.signature - { - name: name, - description: "Will list the 100 most popular tags on the current discourse instance", - } - end - - def self.name - "tags" - end - - def invoke - column_names = { name: "Name", public_topic_count: "Topic Count" } - - tags = - Tag - .where("public_topic_count > 0") - .order(public_topic_count: :desc) - .limit(100) - .pluck(*column_names.keys) - - @last_count = tags.length - - format_results(tags, column_names.values) - end - - private - - def description_args - { count: @last_count || 0 } - end - end - end - end -end diff --git a/lib/personas/tools/option.rb b/lib/personas/tools/option.rb deleted file mode 100644 index 777475b8..00000000 --- a/lib/personas/tools/option.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Option - attr_reader :tool, :name, :type, :values, :default - - def initialize(tool:, name:, type:, values: nil, default: nil) - @tool = tool - @name = name.to_s - @type = type - @values = values - @default = default - end - - def localized_name - I18n.t("discourse_ai.ai_bot.tool_options.#{tool.signature[:name]}.#{name}.name") - end - - def localized_description - I18n.t("discourse_ai.ai_bot.tool_options.#{tool.signature[:name]}.#{name}.description") - end - end - end - end -end diff --git a/lib/personas/tools/random_picker.rb b/lib/personas/tools/random_picker.rb deleted file mode 100644 index 1abd537f..00000000 --- a/lib/personas/tools/random_picker.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class RandomPicker < Tool - def self.signature - { - name: name, - description: - "Handles a variety of random decisions based on the format of each input element", - parameters: [ - { - name: "options", - description: - "An array where each element is either a range (e.g., '1-6') or a comma-separated list of options (e.g., 'sam,jane,joe')", - type: "array", - item_type: "string", - required: true, - }, - ], - } - end - - def self.name - "random_picker" - end - - def options - parameters[:options] - end - - def invoke - result = nil - # can be a naive list of strings - if options.none? { |option| option.match?(/\A\d+-\d+\z/) || option.include?(",") } - result = options.sample - else - result = - options.map do |option| - case option - when /\A\d+-\d+\z/ # Range format, e.g., "1-6" - random_range(option) - when /,/ # Comma-separated values, e.g., "sam,jane,joe" - pick_list(option) - else - "Invalid format: #{option}" - end - end - end - - @last_result = result - { options: options, result: result } - end - - private - - def random_range(range_str) - low, high = range_str.split("-").map(&:to_i) - rand(low..high) - end - - def pick_list(list_str) - list_str.split(",").map(&:strip).sample - end - - def description_args - { options: options, result: @last_result } - end - end - end - end -end diff --git a/lib/personas/tools/read.rb b/lib/personas/tools/read.rb deleted file mode 100644 index 3077de47..00000000 --- a/lib/personas/tools/read.rb +++ /dev/null @@ -1,106 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - MAX_POSTS = 100 - - module Tools - class Read < Tool - def self.signature - { - name: name, - description: "Will read a topic or a post on this Discourse instance", - parameters: [ - { - name: "topic_id", - description: "the id of the topic to read", - type: "integer", - required: true, - }, - { - name: "post_numbers", - description: "the post numbers to read (optional)", - type: "array", - item_type: "integer", - required: false, - }, - ], - } - end - - def self.accepted_options - [option(:read_private, type: :boolean)] - end - - def self.name - "read" - end - - attr_reader :title, :url - - def topic_id - parameters[:topic_id] - end - - def post_numbers - parameters[:post_numbers] - end - - def invoke - not_found = { topic_id: topic_id, description: "Topic not found" } - guardian = Guardian.new(context.user) if options[:read_private] && context.user - guardian ||= Guardian.new - - @title = "" - - topic = Topic.find_by(id: topic_id.to_i) - return not_found if !topic || !guardian.can_see?(topic) - - @title = topic.title - - posts = - Post.secured(guardian).where(topic_id: topic_id).order(:post_number).limit(MAX_POSTS) - - post_number = 1 - post_number = post_numbers.first if post_numbers.present? - - @url = topic.relative_url(post_number) - - posts = posts.where("post_number in (?)", post_numbers) if post_numbers.present? - - content = +<<~TEXT.strip - title: #{topic.title} - TEXT - - category_names = [ - topic.category&.parent_category&.name, - topic.category&.name, - ].compact.join(" ") - content << "\ncategories: #{category_names}" if category_names.present? - - if topic.tags.length > 0 - tags = DiscourseTagging.filter_visible(topic.tags, Guardian.new) - content << "\ntags: #{tags.map(&:name).join(", ")}\n\n" if tags.length > 0 - end - - posts.each do |post| - content << "\n\n#{post.user&.name}(#{post.username}) said:\n\n#{post.raw}" - end - - truncated_content = - truncate(content, max_length: 20_000, percent_length: 0.3, llm: llm).squish - - result = { topic_id: topic_id, content: truncated_content } - result[:post_numbers] = post_numbers if post_numbers.present? - result - end - - protected - - def description_args - { title: title, url: url } - end - end - end - end -end diff --git a/lib/personas/tools/read_artifact.rb b/lib/personas/tools/read_artifact.rb deleted file mode 100644 index e3074d71..00000000 --- a/lib/personas/tools/read_artifact.rb +++ /dev/null @@ -1,211 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class ReadArtifact < Tool - MAX_HTML_SIZE = 30.kilobytes - MAX_CSS_FILES = 5 - - def self.name - "read_artifact" - end - - def self.signature - { - name: "read_artifact", - description: "Read an artifact from a URL and convert to a local artifact", - parameters: [ - { - name: "url", - type: "string", - description: "URL of the artifact to read", - required: true, - }, - ], - } - end - - def invoke - return error_response("Unknown context, feature only works in PMs") if !post - - uri = URI.parse(parameters[:url]) - return error_response("Invalid URL") unless uri.is_a?(URI::HTTP) - - if discourse_artifact?(uri) - handle_discourse_artifact(uri) - else - handle_external_page(uri) - end - end - - def chain_next_response? - @chain_next_response - end - - private - - def error_response(message) - @chain_next_response = true - { status: "error", error: message } - end - - def success_response(artifact) - { status: "success", artifact_id: artifact.id, message: "Artifact created successfully." } - end - - def discourse_artifact?(uri) - uri.path.include?("/discourse-ai/ai-bot/artifacts/") - end - - def post - @post ||= Post.find_by(id: context.post_id) - end - - def handle_discourse_artifact(uri) - if uri.path =~ %r{/discourse-ai/ai-bot/artifacts/(\d+)(?:/(\d+))?} - artifact_id = $1.to_i - version = $2&.to_i - else - return error_response("Invalid artifact URL format") - end - - if uri.host == Discourse.current_hostname - source_artifact = AiArtifact.find_by(id: artifact_id) - return error_response("Artifact not found") if !source_artifact - - if !source_artifact.public? && !Guardian.new(post.user).can_see?(source_artifact.post) - return error_response("Access denied") - end - new_artifact = clone_artifact(source_artifact, version) - else - response = fetch_page(uri) - return error_response("Failed to fetch artifact") unless response - - html, css, js = extract_discourse_artifact(response.body) - return error_response("Invalid artifact format") unless html - - new_artifact = - create_artifact_from_web( - html: html, - css: css, - js: js, - name: "Imported Discourse Artifact", - ) - end - - if new_artifact&.persisted? - update_custom_html(new_artifact) - success_response(new_artifact) - else - error_response( - new_artifact&.errors&.full_messages&.join(", ") || "Failed to create artifact", - ) - end - end - - def extract_discourse_artifact(html) - doc = Nokogiri.HTML(html) - iframe = doc.at_css("body > iframe") - return nil unless iframe - - # parse srcdoc attribute of iframe - iframe_doc = Nokogiri.HTML(iframe["srcdoc"]) - return nil unless iframe_doc - - body = iframe_doc.at_css("body") - last_script_tag = body&.at_css("script:last-of-type") - script = last_script_tag&.content.to_s[0...MAX_HTML_SIZE] - last_script_tag.remove if last_script_tag - content = body&.inner_html.to_s[0...MAX_HTML_SIZE] - style = iframe_doc.at_css("style")&.content.to_s[0...MAX_HTML_SIZE] - - [content, style, script] - end - - def handle_external_page(uri) - response = fetch_page(uri) - return error_response("Failed to fetch page") unless response - - html, css, js = extract_content(response, uri) - new_artifact = - create_artifact_from_web(html: html, css: css, js: js, name: "external artifact") - - if new_artifact&.persisted? - update_custom_html(new_artifact) - success_response(new_artifact) - else - error_response( - new_artifact&.errors&.full_messages&.join(", ") || "Failed to create artifact", - ) - end - end - - def extract_content(response, uri) - doc = Nokogiri.HTML(response.body) - - html = doc.at_css("body").to_html.to_s[0...MAX_HTML_SIZE] - - css_files = - doc - .css('link[rel="stylesheet"]') - .map { |link| URI.join(uri, link["href"]).to_s } - .first(MAX_CSS_FILES) - css = download_css_files(css_files).to_s[0...MAX_HTML_SIZE] - - js = doc.css("script:not([src])").map(&:content).join("\n").to_s[0...MAX_HTML_SIZE] - - [html, css, js] - end - - def clone_artifact(source, version = nil) - source_version = version ? source.versions.find_by(version_number: version) : nil - content = source_version || source - - AiArtifact.create!( - user: post.user, - post: post, - name: source.name, - html: content.html, - css: content.css, - js: content.js, - metadata: { - cloned_from: source.id, - cloned_version: source_version&.version_number, - }, - ) - end - - def create_artifact_from_web(html:, css:, js:, name:) - AiArtifact.create( - user: post.user, - post: post, - name: name, - html: html, - css: css, - js: js, - metadata: { - imported_from: parameters[:url], - }, - ) - end - - def update_custom_html(artifact) - self.custom_raw = <<~HTML - ### Artifact created successfully - - <div class="ai-artifact" data-ai-artifact-id="#{artifact.id}"></div> - HTML - end - - def fetch_page(uri) - send_http_request(uri.to_s) { |response| response if response.code == "200" } - end - - def download_css_files(urls) - urls.map { |url| fetch_page(URI.parse(url)).body }.join("\n") - end - end - end - end -end diff --git a/lib/personas/tools/researcher.rb b/lib/personas/tools/researcher.rb deleted file mode 100644 index b9e9e3e2..00000000 --- a/lib/personas/tools/researcher.rb +++ /dev/null @@ -1,265 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Researcher < Tool - attr_reader :filter, :result_count, :goals, :dry_run - - class << self - def signature - { - name: name, - description: - "Analyze and extract information from content across the forum based on specified filters", - parameters: [ - { name: "filter", description: filter_description, type: "string" }, - { - name: "goals", - description: - "The specific information you want to extract or analyze from the filtered content, you may specify multiple goals", - type: "string", - }, - { - name: "dry_run", - description: "When true, only count matching posts without processing data", - type: "boolean", - }, - ], - } - end - - def filter_description - <<~TEXT - Filter string to target specific content. Space-separated filters use AND logic, OR creates separate filter groups. - - **Filters:** - - username:user1 or usernames:user1,user2 - posts by specific users - - group:group1 or groups:group1,group2 - posts by users in specific groups - - post_type:first|reply - first posts only or replies only - - keywords:word1,word2 - full-text search in post content - - topic_keywords:word1,word2 - full-text search in topics (returns all posts from matching topics) - - topic:123 or topics:123,456 - specific topics by ID - - category:name1 or categories:name1,name2 - posts in categories (by name/slug) - - tag:tag1 or tags:tag1,tag2 - posts in topics with tags - - after:YYYY-MM-DD, before:YYYY-MM-DD - filter by post creation date - - topic_after:YYYY-MM-DD, topic_before:YYYY-MM-DD - filter by topic creation date - - status:open|closed|archived|noreplies|single_user - topic status filters - - max_results:N - limit results (per OR group) - - order:latest|oldest|latest_topic|oldest_topic|likes - sort order - #{assign_tip} - - **OR Logic:** Each OR group processes independently - filters don't cross boundaries. - - Examples: - - 'username:sam after:2023-01-01' - sam's posts after date - - 'max_results:50 category:bugs OR tag:urgent' - (≤50 bug posts) OR (all urgent posts) - TEXT - end - - def assign_tip - if SiteSetting.respond_to?(:assign_enabled) && SiteSetting.assign_enabled - (<<~TEXT).strip - assigned_to:username or assigned_to:username1,username2 - topics assigned to a specific user - assigned_to:* - topics assigned to any user - assigned_to:nobody - topics not assigned to any user - TEXT - end - end - - def name - "researcher" - end - - def accepted_options - [ - option(:researcher_llm, type: :llm), - option(:max_results, type: :integer), - option(:include_private, type: :boolean), - option(:max_tokens_per_post, type: :integer), - option(:max_tokens_per_batch, type: :integer), - ] - end - end - - def invoke(&blk) - max_results = options[:max_results] || 1000 - - @filter = parameters[:filter] || "" - @goals = parameters[:goals] || "" - @dry_run = parameters[:dry_run].nil? ? false : parameters[:dry_run] - - post = Post.find_by(id: context.post_id) - goals = parameters[:goals] || "" - dry_run = parameters[:dry_run].nil? ? false : parameters[:dry_run] - - return { error: "No goals provided" } if goals.blank? - return { error: "No filter provided" } if @filter.blank? - - guardian = nil - guardian = Guardian.new(context.user) if options[:include_private] - - filter = - DiscourseAi::Utils::Research::Filter.new( - @filter, - limit: max_results, - guardian: guardian, - ) - - if filter.invalid_filters.present? - return( - { - error: - "Invalid filter fragment: #{filter.invalid_filters.join(" ")}\n\n#{self.class.filter_description}", - } - ) - end - - @result_count = filter.search.count - - blk.call details - - if dry_run - { dry_run: true, goals: goals, filter: @filter, number_of_posts: @result_count } - else - process_filter(filter, goals, post, &blk) - end - rescue StandardError => e - { error: "Error processing research: #{e.message}" } - end - - def details - if @dry_run - I18n.t("discourse_ai.ai_bot.tool_description.researcher_dry_run", description_args) - else - I18n.t("discourse_ai.ai_bot.tool_description.researcher", description_args) - end - end - - def summary - if @dry_run - I18n.t("discourse_ai.ai_bot.tool_summary.researcher_dry_run") - else - I18n.t("discourse_ai.ai_bot.tool_summary.researcher") - end - end - - def description_args - { count: @result_count || 0, filter: @filter || "", goals: @goals || "" } - end - - protected - - MIN_TOKENS_FOR_RESEARCH = 8000 - MIN_TOKENS_FOR_POST = 50 - - def process_filter(filter, goals, post, &blk) - if researcher_llm.max_prompt_tokens < MIN_TOKENS_FOR_RESEARCH - raise ArgumentError, - "LLM max tokens too low for research. Minimum is #{MIN_TOKENS_FOR_RESEARCH}." - end - - max_tokens_per_batch = options[:max_tokens_per_batch].to_i - if max_tokens_per_batch <= MIN_TOKENS_FOR_RESEARCH - max_tokens_per_batch = researcher_llm.max_prompt_tokens - 2000 - end - - max_tokens_per_post = options[:max_tokens_per_post] - if max_tokens_per_post.nil? - max_tokens_per_post = 2000 - elsif max_tokens_per_post < MIN_TOKENS_FOR_POST - max_tokens_per_post = MIN_TOKENS_FOR_POST - end - - formatter = - DiscourseAi::Utils::Research::LlmFormatter.new( - filter, - max_tokens_per_batch: max_tokens_per_batch, - tokenizer: researcher_llm.tokenizer, - max_tokens_per_post: max_tokens_per_post, - ) - - results = [] - - formatter.each_chunk { |chunk| results << run_inference(chunk[:text], goals, post, &blk) } - - if context.cancel_manager&.cancelled? - { - dry_run: false, - goals: goals, - filter: @filter, - results: "Cancelled by user", - cancelled_by_user: true, - } - else - { dry_run: false, goals: goals, filter: @filter, results: results } - end - end - - def researcher_llm - @researcher_llm ||= - ( - options[:researcher_llm].present? && - LlmModel.find_by(id: options[:researcher_llm].to_i)&.to_llm - ) || self.llm - end - - def run_inference(chunk_text, goals, post, &blk) - return if context.cancel_manager&.cancelled? - - system_prompt = goal_system_prompt(goals) - user_prompt = goal_user_prompt(goals, chunk_text) - - prompt = - DiscourseAi::Completions::Prompt.new( - system_prompt, - messages: [{ type: :user, content: user_prompt }], - post_id: post.id, - topic_id: post.topic_id, - ) - - results = [] - researcher_llm.generate( - prompt, - user: post.user, - feature_name: context.feature_name, - cancel_manager: context.cancel_manager, - ) { |partial| results << partial } - - @progress_dots ||= 0 - @progress_dots += 1 - blk.call(details + "\n\n#{"." * @progress_dots}") - results.join - end - - def goal_system_prompt(goals) - <<~TEXT - You are a researcher tool designed to analyze and extract information from forum content on #{Discourse.base_url}. - The current date is #{::Time.zone.now.strftime("%a, %d %b %Y %H:%M %Z")}. - Your task is to process the provided content and extract relevant information based on the specified goal. - When extracting content ALWAYS include the following: - - Multiple citations using Markdown - - Topic citations: Interesting fact [ref](/t/-/TOPIC_ID) - - Post citations: Interesting fact [ref](/t/-/TOPIC_ID/POST_NUMBER) - - Relevent quotes from the direct source content - - Relevant dates and times from the content - - Your goal is: #{goals} - TEXT - end - - def goal_user_prompt(goals, chunk_text) - <<~TEXT - Here is the content to analyze: - - {{{ - #{chunk_text} - }}} - - Your goal is: #{goals} - TEXT - end - end - end - end -end diff --git a/lib/personas/tools/search.rb b/lib/personas/tools/search.rb deleted file mode 100644 index 869cff58..00000000 --- a/lib/personas/tools/search.rb +++ /dev/null @@ -1,167 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Search < Tool - attr_reader :last_query - - MIN_SEMANTIC_RESULTS = 5 - - class << self - def signature - { - name: name, - description: - "Will search topics in the current discourse instance, when rendering always prefer to link to the topics you find", - parameters: [ - { - name: "search_query", - description: - "Specific keywords to search for, space separated (correct bad spelling, remove connector words)", - type: "string", - }, - { - name: "user", - description: - "Filter search results to this username (only include if user explicitly asks to filter by user)", - type: "string", - }, - { - name: "order", - description: "search result order", - type: "string", - enum: %w[latest latest_topic oldest views likes], - }, - { - name: "max_results", - description: - "limit number of results returned (generally prefer to just keep to default)", - type: "integer", - }, - { - name: "max_posts", - description: - "maximum number of posts on the topics (topics where lots of people posted)", - type: "integer", - }, - { - name: "tags", - description: - "list of tags to search for. Use + to join with OR, use , to join with AND", - type: "string", - }, - { name: "category", description: "category name to filter to", type: "string" }, - { - name: "before", - description: "only topics created before a specific date YYYY-MM-DD", - type: "string", - }, - { - name: "after", - description: "only topics created after a specific date YYYY-MM-DD", - type: "string", - }, - { - name: "status", - description: "search for topics in a particular state", - type: "string", - enum: %w[open closed archived noreplies single_user], - }, - ], - } - end - - def name - "search" - end - - def custom_system_message - <<~TEXT - You were trained on OLD data, lean on search to get up to date information about this forum - When searching try to SIMPLIFY search terms - Discourse search joins all terms with AND. Reduce and simplify terms to find more results. - TEXT - end - - def accepted_options - [ - option(:base_query, type: :string), - option(:max_results, type: :integer), - option(:search_private, type: :boolean), - ] - end - end - - def search_args - parameters.slice(:category, :user, :order, :max_posts, :tags, :before, :after, :status) - end - - def search_query - parameters[:search_query] - end - - def invoke - search_terms = [] - search_terms << options[:base_query] if options[:base_query].present? - search_terms << search_query if search_query.present? - search_args.each { |key, value| search_terms << "#{key}:#{value}" if value.present? } - - @last_query = search_terms.join(" ").to_s - - yield(I18n.t("discourse_ai.ai_bot.searching", query: @last_query)) - - max_results = calculate_max_results(llm) - if parameters[:max_results].to_i > 0 - max_results = [parameters[:max_results].to_i, max_results].min - end - - search_query_with_base = [options[:base_query], search_query].compact.join(" ").strip - - results = - DiscourseAi::Utils::Search.perform_search( - search_query: search_query_with_base, - category: parameters[:category], - user: parameters[:user], - order: parameters[:order], - max_posts: parameters[:max_posts], - tags: parameters[:tags], - before: parameters[:before], - after: parameters[:after], - status: parameters[:status], - max_results: max_results, - current_user: options[:search_private] ? context.user : nil, - ) - - @last_num_results = results[:rows]&.length || 0 - results - end - - protected - - def description_args - { - count: @last_num_results || 0, - query: @last_query || "", - url: "#{Discourse.base_path}/search?q=#{CGI.escape(@last_query || "")}", - } - end - - private - - def calculate_max_results(llm) - max_results = options[:max_results].to_i - return [max_results, 100].min if max_results > 0 - - if llm.max_prompt_tokens > 30_000 - 60 - elsif llm.max_prompt_tokens > 10_000 - 40 - else - 20 - end - end - end - end - end -end diff --git a/lib/personas/tools/search_settings.rb b/lib/personas/tools/search_settings.rb deleted file mode 100644 index fc66c926..00000000 --- a/lib/personas/tools/search_settings.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class SearchSettings < Tool - INCLUDE_DESCRIPTIONS_MAX_LENGTH = 10 - MAX_RESULTS = 200 - - def self.signature - { - name: name, - description: "Will search through site settings and return top 20 results", - parameters: [ - { - name: "query", - description: - "comma delimited list of settings to search for (e.g. 'setting_1,setting_2')", - type: "string", - required: true, - }, - ], - } - end - - def self.name - "search_settings" - end - - def query - parameters[:query].to_s - end - - def all_settings - @all_settings ||= SiteSetting.all_settings - end - - def all_settings=(settings) - # this is only used for testing - @all_settings = settings - end - - def invoke - @last_num_results = 0 - - terms = query.split(",").map(&:strip).map(&:downcase).reject(&:blank?) - - terms_regexes = - terms.map do |term| - regex_string = term.split(/[ _\.\|]/).map { |t| Regexp.escape(t) }.join(".*") - Regexp.new(regex_string, Regexp::IGNORECASE) - end - - found = - all_settings.filter do |setting| - name = setting[:setting].to_s.downcase - description = setting[:description].to_s.downcase - plugin = setting[:plugin].to_s.downcase - - search_string = "#{name} #{description} #{plugin}" - - terms_regexes.any? { |regex| search_string.match?(regex) } - end - - if found.blank? - { - args: { - query: query, - }, - rows: [], - instruction: "no settings matched #{query}, expand your search", - } - else - include_descriptions = false - - if found.length > MAX_RESULTS - found = found[0..MAX_RESULTS] - elsif found.length < INCLUDE_DESCRIPTIONS_MAX_LENGTH - include_descriptions = true - end - - @last_num_results = found.length - - format_results(found, args: { query: query }) do |setting| - result = { name: setting[:setting] } - if include_descriptions - result[:description] = setting[:description] - result[:plugin] = setting[:plugin] - end - result - end - end - end - - protected - - def description_args - { count: @last_num_results || 0, query: parameters[:query].to_s } - end - end - end - end -end diff --git a/lib/personas/tools/setting_context.rb b/lib/personas/tools/setting_context.rb deleted file mode 100644 index e8b390c6..00000000 --- a/lib/personas/tools/setting_context.rb +++ /dev/null @@ -1,165 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class SettingContext < Tool - MAX_CONTEXT_TOKENS = 2000 - CODE_FILE_EXTENSIONS = "rb,js,gjs,hbs" - - class << self - def rg_installed? - if defined?(@rg_installed) - @rg_installed - else - @rg_installed = - begin - Discourse::Utils.execute_command("which", "rg") - true - rescue Discourse::Utils::CommandError - false - end - end - end - - def signature - { - name: name, - description: - "Will provide you with full context regarding a particular site setting in Discourse", - parameters: [ - { - name: "setting_name", - description: "The name of the site setting we need context for", - type: "string", - required: true, - }, - ], - } - end - - def name - "setting_context" - end - end - - def setting_name - @setting_name ||= parameters[:setting_name].to_s.downcase.gsub(" ", "_") - end - - def invoke - if !self.class.rg_installed? - return( - { - setting_name: setting_name, - context: - "This command requires the rg command line tool to be installed on the server", - } - ) - end - - if !SiteSetting.has_setting?(setting_name) - { setting_name: setting_name, context: "This setting does not exist" } - else - description = SiteSetting.description(setting_name) - result = +"# #{setting_name}\n#{description}\n\n" - - setting_info = - find_setting_info(setting_name, [Rails.root.join("config", "site_settings.yml").to_s]) - if !setting_info - setting_info = - find_setting_info(setting_name, Dir[Rails.root.join("plugins/**/settings.yml")]) - end - - result << setting_info - result << "\n\n" - - %w[lib app plugins].each do |dir| - path = Rails.root.join(dir).to_s - result << Discourse::Utils.execute_command( - "rg", - setting_name, - path, - "-g", - "!**/spec/**", - "-g", - "!**/dist/**", - "-g", - "*.{#{CODE_FILE_EXTENSIONS}}", - "-C", - "10", - "--color", - "never", - "--heading", - "--no-ignore", - chdir: path, - success_status_codes: [0, 1], - ) - end - - result.gsub!(/^#{Regexp.escape(Rails.root.to_s)}/, "") - - result = - llm.tokenizer.truncate( - result, - MAX_CONTEXT_TOKENS, - strict: SiteSetting.ai_strict_token_counting, - ) - - { setting_name: setting_name, context: result } - end - end - - private - - def find_setting_info(name, paths) - path, result = nil - - paths.each do |search_path| - result = - Discourse::Utils.execute_command( - "rg", - name, - search_path, - "-g", - "*.{#{CODE_FILE_EXTENSIONS}}", - "-A", - "10", - "--color", - "never", - "--heading", - success_status_codes: [0, 1], - ) - if !result.blank? - path = search_path - break - end - end - - if result.blank? - nil - else - rows = result.split("\n") - leading_spaces = rows[0].match(/^\s*/)[0].length - - filtered = [] - - rows.each do |row| - if !filtered.blank? - break if row.match(/^\s*/)[0].length <= leading_spaces - end - filtered << row - end - - filtered.unshift("#{path}") - filtered.join("\n") - end - end - - def description_args - parameters.slice(:setting_name) - end - end - end - end -end diff --git a/lib/personas/tools/summarize.rb b/lib/personas/tools/summarize.rb deleted file mode 100644 index 94f6cf49..00000000 --- a/lib/personas/tools/summarize.rb +++ /dev/null @@ -1,191 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Summarize < Tool - def self.signature - { - name: name, - description: "Will summarize a topic attempting to answer question in guidance", - parameters: [ - { - name: "topic_id", - description: "The discourse topic id to summarize", - type: "integer", - required: true, - }, - { - name: "guidance", - description: "Special guidance on how to summarize the topic", - type: "string", - }, - ], - } - end - - def self.name - "summary" - end - - def topic_id - parameters[:topic_id].to_i - end - - def guidance - parameters[:guidance] - end - - def chain_next_response? - false - end - - def custom_raw - @last_summary || I18n.t("discourse_ai.ai_bot.topic_not_found") - end - - def invoke(&progress_blk) - topic = nil - if topic_id > 0 - topic = Topic.find_by(id: topic_id) - topic = nil if !topic || !Guardian.new.can_see?(topic) - end - - @last_summary = nil - - if topic - @last_topic_title = topic.title - - posts = - Post - .where(topic_id: topic.id) - .where("post_type in (?)", [Post.types[:regular], Post.types[:small_action]]) - .where("not hidden") - .order(:post_number) - - columns = ["posts.id", :post_number, :raw, :username] - - current_post_numbers = posts.limit(5).pluck(:post_number) - current_post_numbers += posts.reorder("posts.score desc").limit(50).pluck(:post_number) - current_post_numbers += posts.reorder("post_number desc").limit(5).pluck(:post_number) - - data = - Post - .where(topic_id: topic.id) - .joins(:user) - .where("post_number in (?)", current_post_numbers) - .order(:post_number) - .pluck(*columns) - - @last_summary = summarize(data, topic, guidance, bot_user, llm, &progress_blk) - end - - if !@last_summary - "Say: No topic found!" - else - "Topic summarized" - end - end - - protected - - def description_args - { url: "#{Discourse.base_path}/t/-/#{@last_topic_id}", title: @last_topic_title || "" } - end - - private - - def summarize(data, topic, guidance, bot_user, llm, &progress_blk) - text = +"" - data.each do |id, post_number, raw, username| - text << "(#{post_number} #{username} said: #{raw}" - end - - summaries = [] - current_section = +"" - split = [] - - text - .split(/\s+/) - .each_slice(20) do |slice| - current_section << " " - current_section << slice.join(" ") - - # somehow any more will get closer to limits - if llm.tokenizer.tokenize(current_section).length > 2500 - split << current_section - current_section = +"" - end - end - - split << current_section if current_section.present? - - split = split[0..3] + split[-3..-1] if split.length > 5 - - progress = +I18n.t("discourse_ai.ai_bot.summarizing") - progress_blk.call(progress) - - split.each do |section| - progress << "." - progress_blk.call(progress) - - prompt = section_prompt(topic, section, guidance) - - summary = - llm.generate( - prompt, - temperature: 0.6, - max_tokens: 400, - user: bot_user, - feature_name: "summarize_tool", - ) - - summaries << summary - end - - if summaries.length > 1 - progress << "." - progress_blk.call(progress) - - concatenation_prompt = { - insts: "You are a helpful bot", - input: - "concatenated the disjoint summaries, creating a cohesive narrative:\n#{summaries.join("\n")}}", - } - - llm.generate( - concatenation_prompt, - temperature: 0.6, - max_tokens: 500, - user: bot_user, - feature_name: "summarize_tool", - ) - else - summaries.first - end - end - - def section_prompt(topic, text, guidance) - system_prompt = <<~TEXT - You are a summarization bot. - You effectively summarise any text. - You condense it into a shorter version. - You understand and generate Discourse forum markdown. - Try generating links as well the format is #{topic.url}/POST_NUMBER. eg: [ref](#{topic.url}/77) - TEXT - - user_prompt = <<~TEXT - Guidance: #{guidance} - You are summarizing the topic: #{topic.title} - Summarize the following in 400 words: - - #{text} - TEXT - - messages = [{ type: :user, content: user_prompt }] - DiscourseAi::Completions::Prompt.new(system_prompt, messages: messages) - end - end - end - end -end diff --git a/lib/personas/tools/time.rb b/lib/personas/tools/time.rb deleted file mode 100644 index da3d4f43..00000000 --- a/lib/personas/tools/time.rb +++ /dev/null @@ -1,52 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Time < Tool - def self.signature - { - name: name, - description: "Will generate the time in a timezone", - parameters: [ - { - name: "timezone", - description: "ALWAYS supply a Ruby compatible timezone", - type: "string", - required: true, - }, - ], - } - end - - def self.name - "time" - end - - def timezone - parameters[:timezone].to_s - end - - def invoke - time = - begin - ::Time.now.in_time_zone(timezone) - rescue StandardError - nil - end - time = ::Time.now if !time - - @last_time = time.to_s - - { args: { timezone: timezone }, time: time.to_s } - end - - private - - def description_args - { timezone: timezone, time: @last_time } - end - end - end - end -end diff --git a/lib/personas/tools/tool.rb b/lib/personas/tools/tool.rb deleted file mode 100644 index 858750fc..00000000 --- a/lib/personas/tools/tool.rb +++ /dev/null @@ -1,301 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class Tool - # Why 30 mega bytes? - # This general limit is mainly a security feature to avoid tools - # forcing infinite downloads or causing memory exhaustion. - # The limit is somewhat arbitrary and can be increased in future if needed. - MAX_RESPONSE_BODY_LENGTH = 30.megabyte - - class << self - def signature - raise NotImplemented - end - - def name - raise NotImplemented - end - - def custom? - false - end - - def accepted_options - [] - end - - def option(name, type:, values: nil, default: nil) - Option.new(tool: self, name: name, type: type, values: values, default: default) - end - - def help - I18n.t("discourse_ai.ai_bot.tool_help.#{signature[:name]}") - end - - def custom_system_message - nil - end - - def allow_partial_tool_calls? - false - end - - def inject_prompt(prompt:, context:, persona:) - end - end - - # llm being public makes it a bit easier to test - attr_accessor :custom_raw, :parameters, :llm - attr_reader :tool_call_id, :persona_options, :bot_user, :context - - def initialize( - parameters, - tool_call_id: "", - persona_options: {}, - bot_user:, - llm:, - context: nil - ) - @parameters = parameters - @tool_call_id = tool_call_id - @persona_options = persona_options - @bot_user = bot_user - @llm = llm - @context = context.nil? ? DiscourseAi::Personas::BotContext.new(messages: []) : context - if !@context.is_a?(DiscourseAi::Personas::BotContext) - raise ArgumentError, "context must be a DiscourseAi::Personas::Context" - end - end - - def name - self.class.name - end - - def summary - I18n.t("discourse_ai.ai_bot.tool_summary.#{name}") - end - - def details - I18n.t("discourse_ai.ai_bot.tool_description.#{name}", description_args) - end - - def help - I18n.t("discourse_ai.ai_bot.tool_help.#{name}") - end - - def options - result = HashWithIndifferentAccess.new - self.class.accepted_options.each do |option| - val = @persona_options[option.name] - if val - case option.type - when :boolean - val = (val.to_s == "true") - when :integer - val = val.to_i - when :enum - val = val.to_s - val = option.default if option.values && !option.values.include?(val) - end - result[option.name] = val - elsif val.nil? - result[option.name] = option.default - end - end - result - end - - def chain_next_response? - true - end - - protected - - def fetch_default_branch(repo) - api_url = "https://api.github.com/repos/#{repo}" - - response_code = "unknown error" - repo_data = nil - - send_http_request( - api_url, - headers: { - "Accept" => "application/vnd.github.v3+json", - }, - authenticate_github: true, - ) do |response| - response_code = response.code - if response_code == "200" - begin - repo_data = JSON.parse(read_response_body(response)) - rescue JSON::ParserError - response_code = "500 - JSON parse error" - end - end - end - - response_code == "200" ? repo_data["default_branch"] : "main" - end - - def send_http_request( - url, - headers: {}, - authenticate_github: false, - follow_redirects: false, - method: :get, - body: nil, - &blk - ) - self.class.send_http_request( - url, - headers: headers, - authenticate_github: authenticate_github, - follow_redirects: follow_redirects, - method: method, - body: body, - &blk - ) - end - - def self.send_http_request( - url, - headers: {}, - authenticate_github: false, - follow_redirects: false, - method: :get, - body: nil - ) - raise "Expecting caller to use a block" if !block_given? - - uri = nil - url = UrlHelper.normalized_encode(url) - uri = - begin - URI.parse(url) - rescue StandardError - nil - end - - return if !uri - - if follow_redirects - fd = - FinalDestination.new( - url, - validate_uri: true, - max_redirects: 5, - follow_canonical: true, - ) - - uri = fd.resolve - end - - return if uri.blank? - - request = nil - if method == :get - request = FinalDestination::HTTP::Get.new(uri) - elsif method == :post - request = FinalDestination::HTTP::Post.new(uri) - elsif method == :put - request = FinalDestination::HTTP::Put.new(uri) - elsif method == :patch - request = FinalDestination::HTTP::Patch.new(uri) - elsif method == :delete - request = FinalDestination::HTTP::Delete.new(uri) - end - - raise ArgumentError, "Invalid method: #{method}" if !request - - request.body = body if body - - request["User-Agent"] = DiscourseAi::AiBot::USER_AGENT - headers.each { |k, v| request[k] = v } - if authenticate_github && SiteSetting.ai_bot_github_access_token.present? - request["Authorization"] = "Bearer #{SiteSetting.ai_bot_github_access_token}" - end - - FinalDestination::HTTP.start(uri.hostname, uri.port, use_ssl: uri.port != 80) do |http| - http.request(request) { |response| yield response } - end - end - - def self.read_response_body(response, max_length: nil) - max_length ||= MAX_RESPONSE_BODY_LENGTH - - body = +"" - response.read_body do |chunk| - body << chunk - break if body.bytesize > max_length - end - - if body.bytesize > max_length - body[0...max_length].scrub - else - body.scrub - end - end - - def read_response_body(response, max_length: nil) - self.class.read_response_body(response, max_length: max_length) - end - - def truncate(text, llm:, percent_length: nil, max_length: nil) - if !percent_length && !max_length - raise ArgumentError, "You must provide either percent_length or max_length" - end - - target = llm.max_prompt_tokens - target = (target * percent_length).to_i if percent_length - - if max_length - target = max_length if target > max_length - end - - llm.tokenizer.truncate(text, target, strict: SiteSetting.ai_strict_token_counting) - end - - def accepted_options - [] - end - - def option(name, type:) - Option.new(tool: self, name: name, type: type) - end - - def description_args - {} - end - - def format_results(rows, column_names = nil, args: nil) - rows = rows&.map { |row| yield row } if block_given? - - if !column_names - index = -1 - column_indexes = {} - - rows = - rows&.map do |data| - new_row = [] - data.each do |key, value| - found_index = column_indexes[key.to_s] ||= (index += 1) - new_row[found_index] = value - end - new_row - end - column_names = column_indexes.keys - end - - # this is not the most efficient format - # however this is needed cause GPT 3.5 / 4 was steered using JSON - result = { column_names: column_names, rows: rows } - result[:args] = args if args - result - end - end - end - end -end diff --git a/lib/personas/tools/update_artifact.rb b/lib/personas/tools/update_artifact.rb deleted file mode 100644 index 46038d7a..00000000 --- a/lib/personas/tools/update_artifact.rb +++ /dev/null @@ -1,261 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class UpdateArtifact < Tool - def self.name - "update_artifact" - end - - def self.signature - { - name: "update_artifact", - description: "Updates an existing web artifact", - parameters: [ - { - name: "artifact_id", - description: "The ID of the artifact to update", - type: "integer", - required: true, - }, - { - name: "instructions", - description: "Clear instructions on what changes need to be made to the artifact.", - type: "string", - required: true, - }, - { - name: "version", - description: - "The version number of the artifact to update, if not supplied latest version will be updated", - type: "integer", - required: false, - }, - ], - } - end - - def self.inject_prompt(prompt:, context:, persona:) - return if persona.options["do_not_echo_artifact"].to_s == "true" - # we inject the current artifact content into the last user message - if topic_id = context.topic_id - posts = Post.where(topic_id: topic_id) - artifact = AiArtifact.order("id desc").where(post: posts).first - if artifact - latest_version = artifact.versions.order(version_number: :desc).first - current = latest_version || artifact - - artifact_source = <<~MSG - Current Artifact: - - ### HTML - ```html - #{current.html} - ``` - - ### CSS - ```css - #{current.css} - ``` - - ### JavaScript - ```javascript - #{current.js} - ``` - - MSG - - last_message = prompt.messages.last - last_message[:content] = "#{artifact_source}\n\n#{last_message[:content]}" - end - end - end - - def self.accepted_options - [ - option(:editor_llm, type: :llm), - option(:update_algorithm, type: :enum, values: %w[diff full], default: "diff"), - option(:do_not_echo_artifact, type: :boolean, default: true), - ] - end - - def self.allow_partial_tool_calls? - true - end - - def partial_invoke - in_progress(instructions: parameters[:instructions]) if parameters[:instructions].present? - end - - def in_progress(instructions:, source: nil) - source = (<<~HTML) if source.present? - ### Source - - ```` - #{source} - ```` - HTML - - self.custom_raw = <<~HTML - <details> - <summary>Thinking...</summary> - - ### Instructions - ```` - #{instructions} - ```` - - #{source} - - </details> - HTML - end - - def invoke - post = Post.find_by(id: context.post_id) - return error_response("No post context found") unless post - - artifact = AiArtifact.find_by(id: parameters[:artifact_id]) - return error_response("Artifact not found") unless artifact - - artifact_version = nil - if version = parameters[:version] - artifact_version = artifact.versions.find_by(version_number: version) - # we could tell llm it is confused here if artifact version is not there - # but let's just fix it transparently which saves an llm call - end - - artifact_version ||= artifact.versions.order(version_number: :desc).first - - if artifact.post.topic.id != post.topic.id - return error_response("Attempting to update an artifact you are not allowed to") - end - - llm = - ( - options[:editor_llm].present? && - LlmModel.find_by(id: options[:editor_llm].to_i)&.to_llm - ) || self.llm - - strategy = - ( - if options[:update_algorithm] == "diff" - ArtifactUpdateStrategies::Diff - else - ArtifactUpdateStrategies::Full - end - ) - - begin - instructions = parameters[:instructions] - partial_response = +"" - new_version = - strategy - .new( - llm: llm, - post: post, - user: post.user, - artifact: artifact, - artifact_version: artifact_version, - instructions: instructions, - cancel_manager: context.cancel_manager, - ) - .apply do |progress| - partial_response << progress - in_progress(instructions: instructions, source: partial_response) - # force in progress to render - yield nil, true - end - - update_custom_html( - artifact: artifact, - artifact_version: artifact_version, - new_version: new_version, - ) - success_response(artifact, new_version) - rescue StandardError => e - error_response(e.message) - end - end - - def chain_next_response? - false - end - - private - - def line_based_markdown_diff(before, after) - # Split into lines - before_lines = before.split("\n") - after_lines = after.split("\n") - - # Use ONPDiff for line-level comparison - diff = ONPDiff.new(before_lines, after_lines).diff - - # Build markdown output - result = ["```diff"] - - diff.each do |line, status| - case status - when :common - result << " #{line}" - when :delete - result << "-#{line}" - when :add - result << "+#{line}" - end - end - - result << "```" - result.join("\n") - end - - def update_custom_html(artifact:, artifact_version:, new_version:) - content = [] - - if new_version.change_description.present? - content << [ - :description, - "[details='#{I18n.t("discourse_ai.ai_artifact.change_description")}']\n\n````\n#{new_version.change_description}\n````\n\n[/details]", - ] - end - content << [nil, "[details='#{I18n.t("discourse_ai.ai_artifact.view_changes")}']"] - - %w[html css js].each do |type| - source = artifact_version || artifact - old_content = source.public_send(type) - new_content = new_version.public_send(type) - - if old_content != new_content - diff = line_based_markdown_diff(old_content, new_content) - content << [nil, "### #{type.upcase} Changes\n#{diff}"] - end - end - - content << [nil, "[/details]"] - content << [ - :preview, - "### Preview\n\n<div class=\"ai-artifact\" data-ai-artifact-version=\"#{new_version.version_number}\" data-ai-artifact-id=\"#{artifact.id}\"></div>", - ] - - self.custom_raw = content.map { |c| c[1] }.join("\n\n") - end - - def success_response(artifact, version) - { - status: "success", - artifact_id: artifact.id, - version: version.version_number, - message: "Artifact updated successfully and rendered to user.", - } - end - - def error_response(message) - self.custom_raw = "" - { status: "error", error: message } - end - end - end - end -end diff --git a/lib/personas/tools/web_browser.rb b/lib/personas/tools/web_browser.rb deleted file mode 100644 index 15d5ed1e..00000000 --- a/lib/personas/tools/web_browser.rb +++ /dev/null @@ -1,109 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - module Tools - class WebBrowser < Tool - def self.signature - { - name: name, - description: - "Visits a web page, retrieves the HTML content, extracts the main content, converts it to plain text, and returns the result.", - parameters: [ - { - name: "url", - description: "The URL of the web page to visit.", - required: true, - type: "string", - }, - ], - } - end - - def self.name - "web_browser" - end - - def url - return @url if defined?(@url) - @url = parameters[:url] - @url = "https://#{@url}" if !@url.start_with?("http") - - @url - end - - def invoke - send_http_request(url, follow_redirects: true) do |response| - if response.code == "200" - html = read_response_body(response) - text = extract_main_content(html) - text = truncate(text, max_length: 50_000, percent_length: 0.3, llm: llm) - return { url: response.uri.to_s, text: text.strip } - else - return { url: url, error: "Failed to retrieve the web page: #{response.code}" } - end - end - - { url: url, error: "Failed to retrieve the web page" } - rescue StandardError - # keeping information opaque for now just in case - { url: url, error: "Failed to retrieve the web page" } - end - - def description_args - { url: url } - end - - private - - def extract_main_content(html) - doc = Nokogiri.HTML(html) - doc.search("script, style, comment").remove - - main_content = find_main_content(doc) - main_content ||= doc.at("body") - - buffer = +"" - nodes_to_text(main_content, buffer) - - buffer.gsub(/\s+/, " ") - end - - def nodes_to_text(nodes, buffer) - if nodes.text? - buffer << nodes.text - buffer << " " - return - end - - nodes.children.each do |node| - case node.name - when "text" - buffer << node.text - buffer << " " - when "br" - buffer << "\n" - when "a" - nodes_to_text(node, buffer) - buffer << " [#{node["href"]}] " - else - nodes_to_text(node, buffer) - end - end - end - - def find_main_content(doc) - [ - doc.at("article"), - doc.at("main"), - doc.at("[role='main']"), - doc.at("#main"), - doc.at(".main"), - doc.at("#content"), - doc.at(".content"), - ].find(&:present?) - end - end - end - end -end diff --git a/lib/personas/topic_title_translator.rb b/lib/personas/topic_title_translator.rb deleted file mode 100644 index 8c295dee..00000000 --- a/lib/personas/topic_title_translator.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class TopicTitleTranslator < Persona - def self.default_enabled - false - end - - def system_prompt - examples = [ - { - input: { - content: "New Update for Minecraft Adds Underwater Temples", - target_locale: "es", - }.to_json, - output: "Nueva actualización para Minecraft añade templos submarinos", - }, - { - input: { - content: "Toyota announces revolutionary battery technology", - target_locale: "fr", - }.to_json, - output: "Toyota annonce une technologie de batteries révolutionnaire", - }, - { - input: { - content: - "Heathrow fechado: paralisação de voos deve continuar nos próximos dias, diz gestora do aeroporto de Londres", - target_locale: "en", - }.to_json, - output: - "Heathrow closed: flight disruption expected to continue in coming days, says London airport management", - }, - ] - - <<~PROMPT.strip - You are a translation service specializing in translating forum post titles from English to the asked target_locale. Your task is to provide accurate and contextually appropriate translations while adhering to the following guidelines: - - 1. Translate the given title from English to target_locale asked. - 2. Keep proper nouns and technical terms in their original language. - 3. Attempt to keep the translated title length close to the original when possible. - 4. Ensure the translation maintains the original meaning and tone. - - To complete this task: - - 1. Read and understand the title carefully. - 2. Identify any proper nouns or technical terms that should remain untranslated. - 3. Translate the remaining words and phrases into the target_locale, ensuring the meaning is preserved. - 4. Adjust the translation if necessary to keep the length similar to the original title. - 5. Review your translation for accuracy and naturalness in the target_locale. - - Here are three examples of correct translations: - - Input: #{examples[0][:input]} - Output: #{examples[0][:output]} - - Input: #{examples[1][:input]} - Output: #{examples[1][:output]} - - Input: #{examples[2][:input]} - Output: #{examples[2][:output]} - - The text to translate will be provided in JSON format with the following structure: - {"content": "Title to translate", "target_locale": "Target language code"} - - Remember to keep proper nouns like "Minecraft" and "Toyota" in their original form. You are being consumed via an API that expects only the translated title. Only return the translated title in the correct language. Do not add questions or explanations. - PROMPT - end - - def temperature - 0.3 - end - end - end -end diff --git a/lib/personas/translator.rb b/lib/personas/translator.rb deleted file mode 100644 index faf00005..00000000 --- a/lib/personas/translator.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class Translator < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - I want you to act as an {user_language} translator, spelling corrector and improver. I will write to you - in any language and you will detect the language, translate it and answer in the corrected and - improved version of my text, in {user_language}. I want you to replace my simplified A0-level words and - sentences with more beautiful and elegant, upper level {user_language} words and sentences. - Keep the meaning same, but make them more literary. I want you to only reply the correction, - the improvements and nothing else, do not write explanations. - You will find the text between <input></input> XML tags. - - Format your response as a JSON object with a single key named "output", which has the translation as the value. - Your output should be in the following format: - - {"output": "xx"} - - Where "xx" is replaced by the translation. - - reply with valid JSON only - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - - def temperature - 0.2 - end - end - end -end diff --git a/lib/personas/tutor.rb b/lib/personas/tutor.rb deleted file mode 100644 index 9142346f..00000000 --- a/lib/personas/tutor.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Personas - class Tutor < Persona - def self.default_enabled - false - end - - def system_prompt - <<~PROMPT.strip - You are a tutor explaining a term to a student in a specific context. - - I will provide everything you need to know inside <input> tags, which consists of the term I want you - to explain inside <term> tags, the context of where it was used inside <context> tags, the title of - the topic where it was used inside <topic> tags, and optionally, the previous post in the conversation - in <replyTo> tags. - - Using all this information, write a paragraph with a brief explanation - of what the term means. Format the response using Markdown. Reply only with the explanation and - nothing more. - - Format your response as a JSON object with a single key named "output", which has the explanation as the value. - Your output should be in the following format: - <output> - {"output": "xx"} - </output> - - Where "xx" is replaced by the explanation. - PROMPT - end - - def response_format - [{ "key" => "output", "type" => "string" }] - end - end - end -end diff --git a/lib/personas/web_artifact_creator.rb b/lib/personas/web_artifact_creator.rb deleted file mode 100644 index eacd7bc0..00000000 --- a/lib/personas/web_artifact_creator.rb +++ /dev/null @@ -1,56 +0,0 @@ -#frozen_string_literal: true - -module DiscourseAi - module Personas - class WebArtifactCreator < Persona - def tools - [Tools::CreateArtifact, Tools::UpdateArtifact, Tools::ReadArtifact] - end - - def required_tools - [Tools::CreateArtifact, Tools::UpdateArtifact, Tools::ReadArtifact] - end - - def system_prompt - <<~PROMPT - You are the Web Creator, an AI assistant specializing in building interactive web components. You create engaging and functional web experiences using HTML, CSS, and JavaScript. You live in a Discourse PM and communicate using Markdown. - - Core Principles: - - Create delightful, interactive experiences - - Focus on visual appeal and smooth animations - - Write clean, efficient code - - Build progressively (HTML structure → CSS styling → JavaScript interactivity) - - Artifacts run in a sandboxed IFRAME environmment - - Artifacts Discourse persistent storage - requires storage support - - Artifacts have access to current user data (username, name, id) - requires storage support - - When creating: - 1. Understand the desired user experience - 2. Break down complex interactions into simple components - 3. Use semantic HTML for strong foundations - 4. Style thoughtfully with CSS - 5. Add JavaScript for rich interactivity - 6. Consider responsive design - - Best Practices: - - Leverage native HTML elements for better functionality - - Use CSS transforms and transitions for smooth animations - - Keep JavaScript modular and event-driven - - Make content responsive and adaptive - - Create self-contained components - - When responding: - 1. Ask clarifying questions if the request is ambiguous - 2. Briefly explain your approach - 3. Build features iteratively - 4. Describe the interactive elements - 5. Test your solution conceptually - - Your goal is to transform ideas into engaging web experiences. Be creative and practical, focusing on making interfaces that are both beautiful and functional. - - Remember: Great components combine structure (HTML), presentation (CSS), and behavior (JavaScript) to create memorable user experiences. - PROMPT - end - end - end -end diff --git a/lib/post_extensions.rb b/lib/post_extensions.rb deleted file mode 100644 index f82ec5fd..00000000 --- a/lib/post_extensions.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module PostExtensions - extend ActiveSupport::Concern - - prepended do - has_many :classification_results, as: :target - - has_many :sentiment_classifications, - -> { where(classification_type: "sentiment") }, - class_name: "ClassificationResult", - as: :target - - has_many :inferred_concept_posts - has_many :inferred_concepts, through: :inferred_concept_posts - end - end -end diff --git a/lib/sentiment/constants.rb b/lib/sentiment/constants.rb deleted file mode 100644 index 42d21db5..00000000 --- a/lib/sentiment/constants.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - module Constants - SENTIMENT_THRESHOLD = 0.6 - end - end -end diff --git a/lib/sentiment/emotion_dashboard_report.rb b/lib/sentiment/emotion_dashboard_report.rb deleted file mode 100644 index 11c190c5..00000000 --- a/lib/sentiment/emotion_dashboard_report.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class EmotionDashboardReport - def self.register!(plugin) - Emotions::LIST.each do |emotion| - plugin.add_report("emotion_#{emotion}") do |report| - query_results = DiscourseAi::Sentiment::EmotionDashboardReport.fetch_data(report) - report.data = query_results.map { |row| { x: row.day, y: row.send(emotion) } } - if report.facets.include?(:prev_period) && query_results.length > 30 - report.prev30Days = query_results[31..60].sum { |row| row.send(emotion) } - end - end - end - - def self.fetch_data(report) - DB.query(<<~SQL, end: report.end_date, start: report.start_date) - SELECT - posts.created_at::DATE AS day, - #{ - DiscourseAi::Sentiment::Emotions::LIST - .map do |emotion| - "COUNT(*) FILTER (WHERE (classification_results.classification::jsonb->'#{emotion}')::float > 0.1) AS #{emotion}" - end - .join(",\n ") - } - FROM - classification_results - INNER JOIN - posts ON posts.id = classification_results.target_id AND - posts.deleted_at IS NULL AND - posts.created_at BETWEEN :start AND :end - INNER JOIN - topics ON topics.id = posts.topic_id AND - topics.archetype = 'regular' AND - topics.deleted_at IS NULL - WHERE - classification_results.target_type = 'Post' AND - classification_results.model_used = 'SamLowe/roberta-base-go_emotions' - GROUP BY 1 - ORDER BY 1 ASC - SQL - end - end - end - end -end diff --git a/lib/sentiment/emotion_filter_order.rb b/lib/sentiment/emotion_filter_order.rb deleted file mode 100644 index 0f1b3b62..00000000 --- a/lib/sentiment/emotion_filter_order.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class EmotionFilterOrder - def self.register!(plugin) - Emotions::LIST.each do |emotion| - filter_order_emotion = ->(scope, order_direction, _guardian) do - scope_period = - scope - .arel - &.constraints - &.flat_map(&:children) - &.find do |node| - node.is_a?(Arel::Nodes::BoundSqlLiteral) && - node.to_sql.match?(/topics\.bumped_at\s*>=/) - end - &.positional_binds - &.first if scope.to_sql.include?("topics.bumped_at >=") - - if scope_period.is_a?(ActiveSupport::TimeWithZone) - scope_period = "'#{scope_period.to_date}'" - else - # Fallback in case we can't find the scope period - scope_period = "CURRENT_DATE - INTERVAL '1 year'" - end - - emotion_clause = <<~SQL - COUNT(*) FILTER (WHERE (classification_results.classification::jsonb->'#{emotion}')::float > 0.1) - SQL - - # TODO: This is slow, we will need to materialize this in the future - with_clause = <<~SQL - SELECT - topics.id, - #{emotion_clause} AS emotion_#{emotion} - FROM - topics - INNER JOIN - posts ON posts.topic_id = topics.id - INNER JOIN - classification_results ON - classification_results.target_id = posts.id AND - classification_results.target_type = 'Post' AND - classification_results.model_used = 'SamLowe/roberta-base-go_emotions' - WHERE - topics.archetype = 'regular' - AND topics.deleted_at IS NULL - AND posts.deleted_at IS NULL - AND posts.post_type = 1 - AND posts.created_at >= #{scope_period} - GROUP BY - 1 - HAVING - #{emotion_clause} > 0 - SQL - - scope - .with(topic_emotion: Arel.sql(with_clause)) - .joins("INNER JOIN topic_emotion ON topic_emotion.id = topics.id") - .order("topic_emotion.emotion_#{emotion} #{order_direction}") - end - plugin.add_filter_custom_filter("order:emotion_#{emotion}", &filter_order_emotion) - end - end - end - end -end diff --git a/lib/sentiment/emotions.rb b/lib/sentiment/emotions.rb deleted file mode 100644 index d50762e9..00000000 --- a/lib/sentiment/emotions.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class Emotions - LIST = %w[ - admiration - amusement - anger - annoyance - approval - caring - confusion - curiosity - desire - disappointment - disapproval - disgust - embarrassment - excitement - fear - gratitude - grief - joy - love - nervousness - neutral - optimism - pride - realization - relief - remorse - sadness - surprise - ] - end - end -end diff --git a/lib/sentiment/entry_point.rb b/lib/sentiment/entry_point.rb deleted file mode 100644 index 041ccea4..00000000 --- a/lib/sentiment/entry_point.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class EntryPoint - def inject_into(plugin) - sentiment_analysis_cb = - Proc.new do |post| - if SiteSetting.ai_sentiment_enabled - Jobs.enqueue(:post_sentiment_analysis, post_id: post.id) - end - end - - plugin.on(:post_edited, &sentiment_analysis_cb) - - plugin.add_to_serializer(:current_user, :can_see_sentiment_reports) do - ClassificationResult.has_sentiment_classification? && SiteSetting.ai_sentiment_enabled - end - - # TODO we need new interfaces to conditionally register depending on site in the multisite - EmotionFilterOrder.register!(plugin) - EmotionDashboardReport.register!(plugin) - SentimentDashboardReport.register!(plugin) - SentimentAnalysisReport.register!(plugin) - end - end - end -end diff --git a/lib/sentiment/post_classification.rb b/lib/sentiment/post_classification.rb deleted file mode 100644 index 843805b9..00000000 --- a/lib/sentiment/post_classification.rb +++ /dev/null @@ -1,205 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class PostClassification - def self.backfill_query(from_post_id: nil, max_age_days: nil) - available_classifier_names = - DiscourseAi::Sentiment::SentimentSiteSettingJsonSchema.values.map { _1.model_name } - - queries = - available_classifier_names.map do |classifier_name| - base_query = - Post - .includes(:sentiment_classifications) - .joins("INNER JOIN topics ON topics.id = posts.topic_id") - .where(post_type: Post.types[:regular]) - .where.not(topics: { archetype: Archetype.private_message }) - .where(posts: { deleted_at: nil }) - .where(topics: { deleted_at: nil }) - .joins(<<~SQL) - LEFT JOIN classification_results crs - ON crs.target_id = posts.id - AND crs.target_type = 'Post' - AND crs.classification_type = 'sentiment' - AND crs.model_used = '#{classifier_name}' - SQL - .where("crs.id IS NULL") - - base_query = - base_query.where("posts.id >= ?", from_post_id.to_i) if from_post_id.present? - - if max_age_days.present? - base_query = - base_query.where( - "posts.created_at > current_date - INTERVAL '#{max_age_days.to_i} DAY'", - ) - end - - base_query - end - - unioned_queries = queries.map(&:to_sql).join(" UNION ") - - Post.from(Arel.sql("(#{unioned_queries}) as posts")) - end - - CONCURRENT_CLASSFICATIONS = 40 - - def bulk_classify!(relation) - pool = - Scheduler::ThreadPool.new( - min_threads: 0, - max_threads: CONCURRENT_CLASSFICATIONS, - idle_time: 30, - ) - - available_classifiers = classifiers - return if available_classifiers.blank? - - results = Queue.new - queued = 0 - - relation.each do |record| - text = prepare_text(record) - next if text.blank? - - already_classified = record.sentiment_classifications.pluck(&:model_used) - missing_classifiers = - available_classifiers.reject { |ac| already_classified.include?(ac[:model_name]) } - - missing_classifiers.each do |classifier| - pool.post do - result = { target: record, classifier: classifier, text: text } - begin - result[:classification] = request_with(classifier[:client], text) - rescue StandardError => e - result[:error] = e - end - results << result - end - queued += 1 - end - end - - errors = [] - - while queued > 0 - result = results.pop - if result[:error] - errors << result - else - store_classification( - result[:target], - [[result[:classifier][:model_name], result[:classification]]], - ) - end - queued -= 1 - end - - if errors.any? - example_posts = errors.map { |e| e[:target].id }.take(5).join(", ") - Discourse.warn_exception( - errors[0][:error], - "Discourse AI: Errors during bulk classification: Failed to classify #{errors.count} posts (example ids: #{example_posts})", - ) - end - ensure - pool.shutdown - pool.wait_for_termination(timeout: 30) - end - - def classify!(target) - return if target.blank? - available_classifiers = classifiers - return if available_classifiers.blank? - - to_classify = prepare_text(target) - return if to_classify.blank? - - already_classified = target.sentiment_classifications.map(&:model_used) - classifiers_for_target = - available_classifiers.reject { |ac| already_classified.include?(ac[:model_name]) } - - results = - classifiers_for_target.reduce({}) do |memo, cft| - memo[cft[:model_name]] = request_with(cft[:client], to_classify) - memo - end - - store_classification(target, results) - end - - def classifiers - DiscourseAi::Sentiment::SentimentSiteSettingJsonSchema.values.map do |config| - api_endpoint = config.endpoint - - if api_endpoint.present? && api_endpoint.start_with?("srv://") - service = DiscourseAi::Utils::DnsSrv.lookup(api_endpoint.delete_prefix("srv://")) - api_endpoint = "https://#{service.target}:#{service.port}" - end - - { - model_name: config.model_name, - client: - DiscourseAi::Inference::HuggingFaceTextEmbeddings.new(api_endpoint, config.api_key), - } - end - end - - def has_classifiers? - classifiers.present? - end - - private - - def prepare_text(target) - content = - if target.post_number == 1 - "#{target.topic.title}\n#{target.raw}" - else - target.raw - end - - DiscourseAi::Tokenizer::BertTokenizer.truncate( - content, - 512, - strict: SiteSetting.ai_strict_token_counting, - ) - end - - def request_with(client, content) - result = client.classify_by_sentiment!(content) - - transform_result(result) - end - - def transform_result(result) - hash_result = {} - result.each { |r| hash_result[r[:label]] = r[:score] } - hash_result - end - - def store_classification(target, classification) - attrs = - classification.map do |model_name, classifications| - { - model_used: model_name, - target_id: target.id, - target_type: target.class.sti_name, - classification_type: :sentiment, - classification: classifications, - updated_at: DateTime.now, - created_at: DateTime.now, - } - end - - ClassificationResult.upsert_all( - attrs, - unique_by: %i[target_id target_type model_used], - update_only: %i[classification], - ) - end - end - end -end diff --git a/lib/sentiment/sentiment_analysis_report.rb b/lib/sentiment/sentiment_analysis_report.rb deleted file mode 100644 index b820cc28..00000000 --- a/lib/sentiment/sentiment_analysis_report.rb +++ /dev/null @@ -1,182 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class SentimentAnalysisReport - include Constants - GROUP_BY_FILTER_DEFAULT = :category - SORT_BY_FILTER_DEFAULT = :size - - def self.register!(plugin) - plugin.add_report("sentiment_analysis") do |report| - report.modes = [:sentiment_analysis] - - group_by_filter = report.filters.dig(:group_by) || GROUP_BY_FILTER_DEFAULT - report.add_filter( - "group_by", - type: "list", - default: group_by_filter, - choices: [{ id: "category", name: "Category" }, { id: "tag", name: "Tag" }], - allow_any: false, - auto_insert_none_item: false, - ) - - size_filter = report.filters.dig(:sort_by) || SORT_BY_FILTER_DEFAULT - report.add_filter( - "sort_by", - type: "list", - default: size_filter, - choices: [{ id: "size", name: "Size" }, { id: "alphabetical", name: "Alphabetical" }], - allow_any: false, - auto_insert_none_item: false, - ) - - category_id, include_subcategories = - report.add_category_filter(disabled: group_by_filter.to_sym == :tag) - - tag_filter = report.filters.dig(:tag) || "any" - tag_choices = - Tag - .all - .map { |tag| { id: tag.name, name: tag.name } } - .unshift({ id: "any", name: "Any" }) - report.add_filter( - "tag", - type: "list", - default: tag_filter, - choices: tag_choices, - allow_any: false, - auto_insert_none_item: false, - disabled: group_by_filter.to_sym == :category, - ) - - opts = { category_id: category_id, include_subcategories: include_subcategories } - sentiment_data = DiscourseAi::Sentiment::SentimentAnalysisReport.fetch_data(report, opts) - - report.data = sentiment_data - report.labels = [ - I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.positive"), - I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.neutral"), - I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.negative"), - ] - end - end - - def self.fetch_data(report, opts) - threshold = SENTIMENT_THRESHOLD - - grouping = (report.filters.dig(:group_by) || GROUP_BY_FILTER_DEFAULT).to_sym - sorting = (report.filters.dig(:sort_by) || SORT_BY_FILTER_DEFAULT).to_sym - category_filter = report.filters.dig(:category) - tag_filter = report.filters.dig(:tag) - - sentiment_count_sql = Proc.new { |sentiment| <<~SQL } - COUNT( - CASE WHEN (cr.classification::jsonb->'#{sentiment}')::float > :threshold THEN 1 ELSE NULL END - ) - SQL - - grouping_clause = - case grouping - when :category - <<~SQL - c.id AS category_id, - c.name AS category_name, - SQL - when :tag - <<~SQL - tags.name AS tag_name, - SQL - else - raise Discourse::InvalidParameters - end - - group_by_clause = - case grouping - when :category - "GROUP BY c.id, c.name" - when :tag - "GROUP BY tags.name" - else - raise Discourse::InvalidParameters - end - - grouping_join = - case grouping - when :category - <<~SQL - INNER JOIN categories c ON c.id = t.category_id - SQL - when :tag - <<~SQL - INNER JOIN topic_tags tt ON tt.topic_id = p.topic_id - INNER JOIN tags ON tags.id = tt.tag_id - SQL - else - raise Discourse::InvalidParameters - end - - order_by_clause = - case sorting - when :size - "ORDER BY total_count DESC" - when :alphabetical - "ORDER BY 1 ASC" - else - raise Discourse::InvalidParameters - end - - where_clause = - case grouping - when :category - if category_filter.nil? - "" - elsif opts[:include_subcategories] - <<~SQL - AND (c.id = :category_filter OR c.parent_category_id = :category_filter) - SQL - else - "AND c.id = :category_filter" - end - when :tag - if tag_filter.nil? || tag_filter == "any" - "" - else - "AND tags.name = :tag_filter" - end - end - - grouped_sentiments = - DB.query( - <<~SQL, - SELECT - #{grouping_clause} - #{sentiment_count_sql.call("positive")} AS positive_count, - #{sentiment_count_sql.call("negative")} AS negative_count, - COUNT(*) AS total_count - FROM - classification_results AS cr - INNER JOIN posts p ON p.id = cr.target_id AND cr.target_type = 'Post' - INNER JOIN topics t ON t.id = p.topic_id - #{grouping_join} - WHERE - t.archetype = 'regular' AND - p.user_id > 0 AND - cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND - (p.created_at > :report_start AND p.created_at < :report_end) - #{where_clause} - #{group_by_clause} - #{order_by_clause} - SQL - report_start: report.start_date, - report_end: report.end_date, - threshold: threshold, - category_filter: category_filter, - tag_filter: tag_filter, - ) - - grouped_sentiments - end - end - end -end diff --git a/lib/sentiment/sentiment_dashboard_report.rb b/lib/sentiment/sentiment_dashboard_report.rb deleted file mode 100644 index 124e14b8..00000000 --- a/lib/sentiment/sentiment_dashboard_report.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class SentimentDashboardReport - include Constants - - def self.register!(plugin) - plugin.add_report("overall_sentiment") do |report| - report.modes = [:stacked_chart] - threshold = SENTIMENT_THRESHOLD - - sentiment_count_sql = Proc.new { |sentiment| <<~SQL } - COUNT( - CASE WHEN (cr.classification::jsonb->'#{sentiment}')::float > :threshold THEN 1 ELSE NULL END - ) - SQL - - grouped_sentiments = - DB.query( - <<~SQL, - SELECT - DATE_TRUNC('day', p.created_at)::DATE AS posted_at, - #{sentiment_count_sql.call("positive")} - #{sentiment_count_sql.call("negative")} AS sentiment_count - FROM - classification_results AS cr - INNER JOIN posts p ON p.id = cr.target_id AND cr.target_type = 'Post' - INNER JOIN topics t ON t.id = p.topic_id - INNER JOIN categories c ON c.id = t.category_id - WHERE - t.archetype = 'regular' AND - p.user_id > 0 AND - cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND - (p.created_at > :report_start AND p.created_at < :report_end) - GROUP BY DATE_TRUNC('day', p.created_at) - ORDER BY 1 ASC - SQL - report_start: report.start_date, - report_end: report.end_date, - threshold: threshold, - ) - - return report if grouped_sentiments.empty? - - report.data = [ - { - req: "overall_sentiment", - color: report.colors[:lime], - label: I18n.t("discourse_ai.sentiment.reports.overall_sentiment"), - data: - grouped_sentiments.map do |gs| - { x: gs.posted_at, y: gs.public_send("sentiment_count") } - end, - }, - ] - end - end - end - end -end diff --git a/lib/sentiment/sentiment_site_setting_json_schema.rb b/lib/sentiment/sentiment_site_setting_json_schema.rb deleted file mode 100644 index c3003028..00000000 --- a/lib/sentiment/sentiment_site_setting_json_schema.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Sentiment - class SentimentSiteSettingJsonSchema - def self.schema - @schema ||= { - type: "array", - items: { - type: "object", - format: "table", - title: "model", - properties: { - model_name: { - type: "string", - }, - endpoint: { - type: "string", - }, - api_key: { - type: "string", - }, - }, - required: %w[model_name endpoint api_key], - }, - } - end - - def self.values - return {} if SiteSetting.ai_sentiment_model_configs.blank? - JSON.parse(SiteSetting.ai_sentiment_model_configs, object_class: OpenStruct) - end - end - end -end diff --git a/lib/summarization.rb b/lib/summarization.rb deleted file mode 100644 index a7b69763..00000000 --- a/lib/summarization.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - class << self - def topic_summary(topic) - return nil if !SiteSetting.ai_summarization_enabled - if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank? - return nil - end - - persona_klass = ai_persona.class_instance - llm_model = find_summarization_model(persona_klass) - return nil if llm_model.blank? - - DiscourseAi::Summarization::FoldContent.new( - build_bot(persona_klass, llm_model), - DiscourseAi::Summarization::Strategies::TopicSummary.new(topic), - ) - end - - def topic_gist(topic) - return nil if !SiteSetting.ai_summarization_enabled - if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summary_gists_persona)).blank? - return nil - end - - persona_klass = ai_persona.class_instance - llm_model = find_summarization_model(persona_klass) - return nil if llm_model.blank? - - DiscourseAi::Summarization::FoldContent.new( - build_bot(persona_klass, llm_model), - DiscourseAi::Summarization::Strategies::HotTopicGists.new(topic), - ) - end - - def chat_channel_summary(channel, time_window_in_hours) - return nil if !SiteSetting.ai_summarization_enabled - if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank? - return nil - end - - persona_klass = ai_persona.class_instance - llm_model = find_summarization_model(persona_klass) - return nil if llm_model.blank? - - DiscourseAi::Summarization::FoldContent.new( - build_bot(persona_klass, llm_model), - DiscourseAi::Summarization::Strategies::ChatMessages.new(channel, time_window_in_hours), - persist_summaries: false, - ) - end - - # Priorities are: - # 1. Persona's default LLM - # 2. Hidden `ai_summarization_model` setting - # 3. Newest LLM config - def find_summarization_model(persona_klass) - model_id = - persona_klass.default_llm_id || SiteSetting.ai_summarization_model&.split(":")&.last # Remove legacy custom provider. - - if model_id.present? - LlmModel.find_by(id: model_id) - else - LlmModel.last - end - end - - ### Private - - def build_bot(persona_klass, llm_model) - persona = persona_klass.new - user = User.find_by(id: persona_klass.user_id) || Discourse.system_user - - bot = DiscourseAi::Personas::Bot.as(user, persona: persona, model: llm_model) - end - end - end -end diff --git a/lib/summarization/entry_point.rb b/lib/summarization/entry_point.rb deleted file mode 100644 index ca9cd6d5..00000000 --- a/lib/summarization/entry_point.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - class EntryPoint - def inject_into(plugin) - plugin.add_to_serializer(:current_user, :can_summarize) do - return false if !SiteSetting.ai_summarization_enabled - - if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank? - return false - end - scope.user.in_any_groups?(ai_persona.allowed_group_ids.to_a) - end - - plugin.add_to_serializer(:topic_view, :summarizable) do - scope.can_see_summary?(object.topic) - end - - plugin.add_to_serializer(:web_hook_topic_view, :summarizable) do - scope.can_see_summary?(object.topic) - end - - # Don't add gists to the following topic lists. - gist_skipped_lists = %i[suggested semantic_related] - - plugin.register_modifier(:topic_query_create_list_topics) do |topics, options| - if SiteSetting.ai_summarization_enabled && SiteSetting.ai_summary_gists_enabled && - !gist_skipped_lists.include?(options[:filter]) - topics.includes(:ai_gist_summary) - else - topics - end - end - - plugin.add_to_serializer( - :topic_list_item, - :ai_topic_gist, - include_condition: -> { scope.can_see_gists? }, - ) do - return if gist_skipped_lists.include?(options[:filter]) - object.ai_gist_summary&.summarized_text - end - - # As this event can be triggered quite often, let's be overly cautious enqueueing - # jobs if the feature is disabled. - plugin.on(:post_created) do |post| - if SiteSetting.discourse_ai_enabled && SiteSetting.ai_summarization_enabled && - SiteSetting.ai_summary_gists_enabled && post.topic - Jobs.enqueue(:fast_track_topic_gist, topic_id: post&.topic_id) - end - end - end - end - end -end diff --git a/lib/summarization/fold_content.rb b/lib/summarization/fold_content.rb deleted file mode 100644 index 91634ea9..00000000 --- a/lib/summarization/fold_content.rb +++ /dev/null @@ -1,174 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - # This class offers a generic way of summarizing content from multiple sources using different prompts. - # - # It summarizes large amounts of content by recursively summarizing it in smaller chunks that - # fit the given model context window, finally concatenating the disjoint summaries - # into a final version. - # - class FoldContent - def initialize(bot, strategy, persist_summaries: true) - @bot = bot - @strategy = strategy - @persist_summaries = persist_summaries - end - - attr_reader :bot, :strategy - - # @param user { User } - User object used for auditing usage. - # @param &on_partial_blk { Block - Optional } - The passed block will get called with the LLM partial response. - # Note: The block is only called with results of the final summary, not intermediate summaries. - # - # This method doesn't care if we already have an up to date summary. It always regenerate. - # - # @returns { AiSummary } - Resulting summary. - def summarize(user, &on_partial_blk) - truncated_content = content_to_summarize.map { |cts| truncate(cts) } - - summary = fold(truncated_content, user, &on_partial_blk) - - if persist_summaries - AiSummary.store!(strategy, llm_model, summary, truncated_content, human: user&.human?) - else - AiSummary.new(summarized_text: summary) - end - end - - # @returns { AiSummary } - Resulting summary. - # - # Finds a summary matching the target and strategy. Marks it as outdated if the strategy found newer content - def existing_summary - if !defined?(@existing_summary) - summary = AiSummary.find_by(target: strategy.target, summary_type: strategy.type) - - if summary - @existing_summary = summary - - if summary.original_content_sha != latest_sha || - content_to_summarize.any? { |cts| cts[:last_version_at] > summary.updated_at } - summary.mark_as_outdated - end - end - end - @existing_summary - end - - def delete_cached_summaries! - AiSummary.where(target: strategy.target, summary_type: strategy.type).destroy_all - end - - private - - attr_reader :persist_summaries - - def llm_model - bot.llm.llm_model - end - - def content_to_summarize - @targets_data ||= strategy.targets_data - end - - def latest_sha - @latest_sha ||= AiSummary.build_sha(content_to_summarize.map { |c| c[:id] }.join) - end - - # @param items { Array<Hash> } - Content to summarize. Structure will be: { poster: who wrote the content, id: a way to order content, text: content } - # @param user { User } - User object used for auditing usage. - # @param &on_partial_blk { Block - Optional } - The passed block will get called with the LLM partial response. - # Note: The block is only called with results of the final summary, not intermediate summaries. - # - # The summarization algorithm. - # It will summarize as much content summarize given the model's context window. If will prioriotize newer content in case it doesn't fit. - # - # @returns { String } - Resulting summary. - def fold(items, user, &on_partial_blk) - tokenizer = llm_model.tokenizer_class - tokens_left = available_tokens - content_in_window = [] - - items.each_with_index do |item, idx| - as_text = "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " - - if tokenizer.below_limit?( - as_text, - tokens_left, - strict: SiteSetting.ai_strict_token_counting, - ) - content_in_window << item - tokens_left -= tokenizer.size(as_text) - else - break - end - end - - context = - DiscourseAi::Personas::BotContext.new( - user: user, - skip_tool_details: true, - feature_name: strategy.feature, - resource_url: "#{Discourse.base_path}/t/-/#{strategy.target.id}", - messages: strategy.as_llm_messages(content_in_window), - ) - - summary = +"" - - buffer_blk = - Proc.new do |partial, _, type| - if type == :structured_output - json_summary_schema_key = bot.persona.response_format&.first.to_h - partial_summary = - partial.read_buffered_property(json_summary_schema_key["key"]&.to_sym) - - if !partial_summary.nil? && !partial_summary.empty? - summary << partial_summary - on_partial_blk.call(partial_summary) if on_partial_blk - end - elsif type.blank? - # Assume response is a regular completion. - summary << partial - on_partial_blk.call(partial) if on_partial_blk - end - end - - bot.reply(context, &buffer_blk) - - summary - end - - def available_tokens - # Reserve tokens for the response and the base prompt - # ~500 words - reserved_tokens = 700 - - llm_model.max_prompt_tokens - reserved_tokens - end - - def truncate(item) - item_content = item[:text].to_s - split_1, split_2 = - [item_content[0, item_content.size / 2], item_content[(item_content.size / 2)..-1]] - - truncation_length = 500 - tokenizer = llm_model.tokenizer_class - - item[:text] = [ - tokenizer.truncate( - split_1, - truncation_length, - strict: SiteSetting.ai_strict_token_counting, - ), - tokenizer.truncate( - split_2.reverse, - truncation_length, - strict: SiteSetting.ai_strict_token_counting, - ).reverse, - ].join(" ") - - item - end - end - end -end diff --git a/lib/summarization/strategies/base.rb b/lib/summarization/strategies/base.rb deleted file mode 100644 index c56f93f3..00000000 --- a/lib/summarization/strategies/base.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - module Strategies - # Objects inheriting from this class will get passed as a dependency to `DiscourseAi::Summarization::FoldContent`. - # This collaborator knows how to source the content to summarize and the prompts used in the process, - # one for summarizing a chunk and another for concatenating them if necessary. - class Base - def initialize(target) - @target = target - end - - attr_reader :target, :opts - - # The summary type differentiates instances of `AiSummary` pointing to a single target. - # See the `summary_type` enum for available options. - def type - raise NotImplementedError - end - - # @returns { Array<Hash> } - Content to summarize. - # - # This method returns an array of hashes with the content to summarize using the following structure: - # - # { - # poster: A way to tell who write the content, - # id: A number to signal order, - # text: Text to summarize - # } - # - def targets_data - raise NotImplementedError - end - - # @returns { Array } - Prompt messages to send to the LLM for summarizing content. - def as_llm_messages(_input) - raise NotImplementedError - end - - # We'll pass this as the feature_name when doing LLM calls. - def feature - "summarize" - end - end - end - end -end diff --git a/lib/summarization/strategies/chat_messages.rb b/lib/summarization/strategies/chat_messages.rb deleted file mode 100644 index d0d7ba68..00000000 --- a/lib/summarization/strategies/chat_messages.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - module Strategies - class ChatMessages < Base - def type - AiSummary.summary_types[:complete] - end - - def highest_target_number - nil # We don't persist so we can return nil. - end - - def initialize(target, since) - super(target) - @since = since - end - - def targets_data - target - .chat_messages - .where("chat_messages.created_at > ?", since.hours.ago) - .includes(:user) - .order(created_at: :asc) - .pluck(:id, :username_lower, :message, :updated_at) - .map { { id: _1, poster: _2, text: _3, last_version_at: _4 } } - end - - def as_llm_messages(contents) - content_title = target.name - input = - contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join - - [{ type: :user, content: <<~TEXT.strip }] - #{content_title.present? ? "These texts come from a chat channel called " + content_title + ".\n" : ""} - - Here are the texts, inside <input></input> XML tags: - - <input> - #{input} - </input> - - Generate a summary of the given chat messages. - TEXT - end - - private - - attr_reader :since - end - end - end -end diff --git a/lib/summarization/strategies/hot_topic_gists.rb b/lib/summarization/strategies/hot_topic_gists.rb deleted file mode 100644 index cb987824..00000000 --- a/lib/summarization/strategies/hot_topic_gists.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - module Strategies - class HotTopicGists < Base - def type - AiSummary.summary_types[:gist] - end - - def feature - "gists" - end - - def highest_target_number - target.highest_post_number - end - - def targets_data - op_post_number = 1 - - hot_topics_recent_cutoff = Time.zone.now - SiteSetting.hot_topics_recent_days.days - - recent_hot_posts = - Post - .where(topic_id: target.id) - .where("post_type = ?", Post.types[:regular]) - .where("NOT hidden") - .where("created_at >= ?", hot_topics_recent_cutoff) - .pluck(:post_number) - - # It may happen that a topic is hot without any recent posts - # In that case, we'll just grab the last 20 posts - # for an useful summary of the current state of the topic - if recent_hot_posts.empty? - recent_hot_posts = - Post - .where(topic_id: target.id) - .where("post_type = ?", Post.types[:regular]) - .where("NOT hidden") - .order("post_number DESC") - .limit(20) - .pluck(:post_number) - end - - posts_data = - Post - .where(topic_id: target.id) - .joins(:user) - .where("post_number IN (?)", recent_hot_posts << op_post_number) - .order(:post_number) - .pluck(:post_number, :raw, :username, :last_version_at) - - posts_data.reduce([]) do |memo, (pn, raw, username, last_version_at)| - raw_text = raw - - if pn == 1 && target.topic_embed&.embed_content_cache.present? - raw_text = target.topic_embed&.embed_content_cache - end - - memo << { poster: username, id: pn, text: raw_text, last_version_at: last_version_at } - end - end - - def as_llm_messages(contents) - content_title = target.title - statements = - contents.to_a.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " } - - context = +<<~TEXT - ### Context: - - #{content_title.present? ? "The discussion title is: " + content_title + ". (DO NOT REPEAT THIS IN THE SUMMARY)\n" : ""} - - The conversation began with the following statement: - - #{statements.shift}\n - TEXT - - if statements.present? - context << <<~TEXT - Subsequent discussion includes the following: - - #{statements.join("\n")} - - Your task is to focus on these latest messages, capturing their meaning in the context of the initial statement. - TEXT - else - context << "Your task is to capture the meaning of the initial statement." - end - - [{ type: :user, content: context }] - end - end - end - end -end diff --git a/lib/summarization/strategies/topic_summary.rb b/lib/summarization/strategies/topic_summary.rb deleted file mode 100644 index f06b67ab..00000000 --- a/lib/summarization/strategies/topic_summary.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Summarization - module Strategies - class TopicSummary < Base - def type - AiSummary.summary_types[:complete] - end - - def highest_target_number - target.highest_post_number - end - - def targets_data - post_attributes = %i[post_number raw username last_version_at] - if SiteSetting.enable_names && !SiteSetting.prioritize_username_in_ux - post_attributes.push(:name) - end - - posts_data = (target.has_summary? ? best_replies : pick_selection).pluck(post_attributes) - - posts_data.reduce([]) do |memo, (pn, raw, username, last_version_at, name)| - raw_text = raw - - if pn == 1 && target.topic_embed&.embed_content_cache.present? - raw_text = target.topic_embed&.embed_content_cache - end - - display_name = name.presence || username - - memo << { - poster: display_name, - id: pn, - text: raw_text, - last_version_at: last_version_at, - } - end - end - - def as_llm_messages(contents) - resource_path = "#{Discourse.base_path}/t/-/#{target.id}" - content_title = target.title - input = - contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join - - [{ type: :user, content: <<~TEXT.strip }] - #{content_title.present? ? "The discussion title is: " + content_title + ".\n" : ""} - Here are the posts, inside <input></input> XML tags: - - <input> - #{input} - </input> - - Generate a concise, coherent summary of the text above maintaining the original language. - TEXT - end - - private - - attr_reader :topic - - def best_replies - Post - .summary(target.id) - .where("post_type = ?", Post.types[:regular]) - .where("NOT hidden") - .joins(:user) - .order(:post_number) - end - - def pick_selection - posts = - Post - .where(topic_id: target.id) - .where("post_type = ?", Post.types[:regular]) - .where("NOT hidden") - .order(:post_number) - - post_numbers = posts.limit(5).pluck(:post_number) - post_numbers += posts.reorder("posts.score desc").limit(50).pluck(:post_number) - post_numbers += posts.reorder("post_number desc").limit(5).pluck(:post_number) - - Post - .where(topic_id: target.id) - .joins(:user) - .where("post_number in (?)", post_numbers) - .order(:post_number) - end - end - end - end -end diff --git a/lib/tasks/create_topics.rake b/lib/tasks/create_topics.rake deleted file mode 100644 index 3095345b..00000000 --- a/lib/tasks/create_topics.rake +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -namespace :ai do - desc "Generate topics with AI post content using random users and categories. Use it this way rake ai:generate_topics['title1\,title2\,title3']" - task :generate_topics, [:titles] => [:environment] do |task, args| - titles = args[:titles].include?(",") ? args[:titles].split(",").map(&:strip) : [args[:titles]] - puts "Will create #{titles.size} #{"topics".pluralize(titles.size)}: #{titles.join(", ")}" - - titles.each do |title| - next if Topic.find_by_title(TextCleaner.clean_title(TextSentinel.title_sentinel(title).text)) - category = - Category - .where.not(id: SiteSetting.uncategorized_category_id) - .where(read_restricted: false) - .order("RANDOM()") - .first - users = User.real.activated.not_suspended.where(staged: false).order("RANDOM()").limit(12) - RateLimiter.disable - - creator = TopicGenerator.new(title) - first_post = creator.get_first_post - replies_count = rand(4..10) - replies = creator.get_replies(replies_count) - - post = create_topic(category, first_post, title, users) - replies.each_with_index { |reply, i| create_post(users[i + 1], post.topic_id, reply) } - puts "'#{title}' has #{replies.size} replies" - end - end - - def create_topic(category, first_post, title, users) - puts "#{users.first.username} will create topic '#{title}' in category '#{category.name}'" - post = - PostCreator.create!( - users.first, - title: title, - raw: first_post, - category: category.id, - skip_guardian: true, - ) - puts "Created topic '#{title}' (#{post.topic_id}) in category '#{category.name}'" - post - end - - def create_post(user, topic_id, raw) - puts "#{user.username} will reply to topic #{topic_id}" - PostCreator.create!(user, topic_id:, raw:, skip_guardian: true) - end - - class TopicGenerator - def initialize(title) - @title = title - end - - def get_first_post - TopicGenerator.generate(<<~PROMPT) - Write and opening topic about title: #{@title}. - - content must be in the same language as title - - content in markdown - - content should exclude the title - - maximum of 200 words - PROMPT - end - - def get_replies(count) - JSON.parse( - TopicGenerator.generate(<<~PROMPT).gsub(/```json\n?|\```/, "").gsub(/,\n\n/, ",\n").strip, - Write #{count} replies to a topic with title #{@title}. - - respond in an array of strings within double quotes ["", "", ""] - - each with a maximum of 100 words - - keep to same language of title - - each reply may contain markdown to bold, italicize, link, or bullet point - - do not return anything else other than the array - - the last item in the array should not have a trailing comma - - Example return value ["I agree with you. So and so...", "It is fun ... etc"] - PROMPT - ) - end - - private - - def self.generate(prompt) - return "" if prompt.blank? - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a forum user writing concise, informative posts. Keep responses focused and natural.", - messages: [{ type: :user, content: prompt, id: "user" }], - ) - - DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model).generate( - prompt, - user: Discourse.system_user, - feature_name: "topic-generator", - ) - rescue => e - Rails.logger.error("AI TopicGenerator Error: #{e.message}") - "" - end - end -end diff --git a/lib/tasks/modules/ai_moderation/scan.rake b/lib/tasks/modules/ai_moderation/scan.rake deleted file mode 100644 index a8b295fc..00000000 --- a/lib/tasks/modules/ai_moderation/scan.rake +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -desc "Scan first posts of topics from a date, end date is optional. Usage: rake ai:spam:scan_topics[2024-01-01,2024-02-31]" -task "ai:spam:scan_topics", %i[start_date end_date] => [:environment] do |_, args| - start_date = args[:start_date] ? DateTime.parse(args[:start_date]) : 1.day.ago - end_date = args[:end_date] ? DateTime.parse(args[:end_date]) : Time.current - - scope = Topic.joins(:posts).where(created_at: start_date..end_date).where("posts.post_number = 1") - puts "Processing #{scope.count} topics from #{start_date} to #{end_date}" - scope - .select("topics.id, posts.id as post_id") - .find_each(batch_size: 500) do |record| - Jobs.enqueue(:ai_spam_scan, post_id: record.post_id) - print "." - end -end - -desc "Scan posts from a date, end date is optional. Usage: rake ai:spam:scan_posts[2024-01-31,2024-02-01]" -task "ai:spam:scan_posts", %i[start_date end_date] => [:environment] do |_, args| - start_date = args[:start_date] ? DateTime.parse(args[:start_date]) : 1.day.ago - end_date = args[:end_date] ? DateTime.parse(args[:end_date]) : Time.current - - scope = Post.where(created_at: start_date..end_date).select(:id) - puts "Processing #{scope.count} posts from #{start_date} to #{end_date}" - scope.find_each(batch_size: 500) do |post| - Jobs.enqueue(:ai_spam_scan, post_id: post.id) - print "." - end -end diff --git a/lib/tasks/modules/embeddings/database.rake b/lib/tasks/modules/embeddings/database.rake deleted file mode 100644 index 1981e6ca..00000000 --- a/lib/tasks/modules/embeddings/database.rake +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -desc "Backfill embeddings for all topics and posts" -task "ai:embeddings:backfill", %i[embedding_def_id concurrency] => [:environment] do |_, args| - public_categories = Category.where(read_restricted: false).pluck(:id) - - if args[:embedding_def_id].present? - vdef = EmbeddingDefinition.find(args[:embedding_def_id]) - vector_rep = DiscourseAi::Embeddings::Vector.new(vdef) - else - vector_rep = DiscourseAi::Embeddings::Vector.instance - end - topics_table_name = DiscourseAi::Embeddings::Schema::TOPICS_TABLE - - topics = - Topic - .joins("LEFT JOIN #{topics_table_name} ON #{topics_table_name}.topic_id = topics.id") - .where("#{topics_table_name}.topic_id IS NULL") - .where("category_id IN (?)", public_categories) - .where(deleted_at: nil) - .order("topics.id DESC") - - Parallel.each(topics.all, in_processes: args[:concurrency].to_i, progress: "Topics") do |t| - ActiveRecord::Base.connection_pool.with_connection do - vector_rep.generate_representation_from(t) - end - end - - posts_table_name = DiscourseAi::Embeddings::Schema::POSTS_TABLE - posts = - Post - .joins("LEFT JOIN #{posts_table_name} ON #{posts_table_name}.post_id = posts.id") - .where("#{posts_table_name}.post_id IS NULL") - .where(deleted_at: nil) - .order("posts.id DESC") - - Parallel.each(posts.all, in_processes: args[:concurrency].to_i, progress: "Posts") do |t| - ActiveRecord::Base.connection_pool.with_connection do - vector_rep.generate_representation_from(t) - end - end -end diff --git a/lib/tasks/modules/sentiment/backfill.rake b/lib/tasks/modules/sentiment/backfill.rake deleted file mode 100644 index a9ade1ba..00000000 --- a/lib/tasks/modules/sentiment/backfill.rake +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -desc "Backfill sentiment for all posts" -task "ai:sentiment:backfill", [:start_post] => [:environment] do |_, args| - DiscourseAi::Sentiment::PostClassification - .backfill_query(from_post_id: args[:start_post].to_i) - .find_in_batches do |batch| - print "." - DiscourseAi::Sentiment::PostClassification.new.bulk_classify!(batch) - end -end diff --git a/lib/tasks/modules/sentiment/populate.rake b/lib/tasks/modules/sentiment/populate.rake deleted file mode 100644 index b632431d..00000000 --- a/lib/tasks/modules/sentiment/populate.rake +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -desc "Creates sample sentiment / emotion data" -task "ai:sentiment:populate", [:start_post] => [:environment] do |_, args| - raise "Don't run this task in production!" if Rails.env.production? - - Post - .joins(<<~SQL) - LEFT JOIN classification_results ON - posts.id = classification_results.target_id AND - classification_results.target_type = 'Post' AND - model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' - SQL - .where("classification_results.id IS NULL") - .where("posts.id > ?", args[:start_post].to_i || 0) - .find_each do |post| - positive = rand(0.0..1.0) - negative = rand(0.0..(1.0 - positive)) - neutral = 1 - positive - negative - - ClassificationResult.create!( - target_id: post.id, - model_used: "cardiffnlp/twitter-roberta-base-sentiment-latest", - classification_type: "sentiment", - target_type: "Post", - classification: { - neutral: neutral, - positive: positive, - negative: negative, - }, - ) - end - - Post - .joins(<<~SQL) - LEFT JOIN classification_results ON - posts.id = classification_results.target_id AND - classification_results.target_type = 'Post' AND - classification_results.model_used = 'SamLowe/roberta-base-go_emotions' - SQL - .where("classification_results.id IS NULL") - .where("posts.id > ?", args[:start_post].to_i || 0) - .find_each do |post| - emotions = - DiscourseAi::Sentiment::Emotions::LIST - .shuffle - .reduce({}) do |acc, emotion| - current_sum = acc.values.sum - acc.merge(emotion => rand(0.0..(1.0 - current_sum))) - end - - emotions["neutral"] = 1 - (emotions.values.sum - emotions["neutral"]) - - ClassificationResult.create!( - target_id: post.id, - model_used: "SamLowe/roberta-base-go_emotions", - classification_type: "sentiment", - target_type: "Post", - classification: emotions, - ) - end -end diff --git a/lib/tasks/modules/toxicity/calibration.rake b/lib/tasks/modules/toxicity/calibration.rake deleted file mode 100644 index 30ed632e..00000000 --- a/lib/tasks/modules/toxicity/calibration.rake +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -def classify(content) - ::DiscourseAi::Inference::DiscourseClassifier.perform!( - "#{SiteSetting.ai_toxicity_inference_service_api_endpoint}/api/v1/classify", - SiteSetting.ai_toxicity_inference_service_api_model, - content, - SiteSetting.ai_toxicity_inference_service_api_key, - ) -end - -desc "Uses existing flagged posts to suggest a configuration threshold" -task "ai:toxicity:calibration_stats", [:set_size] => [:environment] do |_, args| - flag_agreed = - PostAction - .where(post_action_type_id: 4, disagreed_at: nil, deferred_at: nil) - .where("post_actions.user_id > 0") - .includes(:post, :user) - .where(user: { admin: false, moderator: false }) - .where("posts.raw IS NOT NULL") - .order(created_at: :desc) - .limit(args[:set_size]) - .pluck(:raw) - - flag_not_agreed = - PostAction - .where(post_action_type_id: 4) - .where("(disagreed_at IS NOT NULL OR deferred_at IS NOT NULL)") - .where("post_actions.user_id > 0") - .includes(:post, :user) - .where(user: { admin: false, moderator: false }) - .where("posts.raw IS NOT NULL") - .order(created_at: :desc) - .limit(args[:set_size]) - .pluck(:raw) - - flag_agreed_scores = flag_agreed.map { classify(_1) } - flag_not_agreed_scores = flag_not_agreed.map { classify(_1) } - - DiscourseAi::Toxicity::Classifier::CLASSIFICATION_LABELS.each do |label| - puts "Label: #{label}" - - label_agreed_scores = flag_agreed_scores.map { _1[label] } - label_not_agreed_scores = flag_not_agreed_scores.map { _1[label] } - - puts "Flagged posts score:" - puts "Max: #{label_agreed_scores.max}" - puts "Min: #{label_agreed_scores.min}" - puts "Avg: #{label_agreed_scores.sum(0.0) / label_agreed_scores.size}" - puts "Median: #{label_agreed_scores.sort[label_agreed_scores.size / 2]}" - puts "Stddev: #{Math.sqrt(label_agreed_scores.map { (_1 - label_agreed_scores.sum(0.0) / label_agreed_scores.size)**2 }.sum(0.0) / label_agreed_scores.size)}" - - puts "Flagged posts score:" - puts "Max: #{label_not_agreed_scores.max}" - puts "Min: #{label_not_agreed_scores.min}" - puts "Avg: #{label_not_agreed_scores.sum(0.0) / label_not_agreed_scores.size}" - puts "Median: #{label_not_agreed_scores.sort[label_not_agreed_scores.size / 2]}" - puts "Stddev: #{Math.sqrt(label_not_agreed_scores.map { (_1 - label_not_agreed_scores.sum(0.0) / label_not_agreed_scores.size)**2 }.sum(0.0) / label_not_agreed_scores.size)}" - - best_cutoff = 0 - best_cutoff_score = 0 - - (0..100) - .step(1) - .each do |cutoff| - score = - label_agreed_scores.count { _1 > cutoff } + label_not_agreed_scores.count { _1 <= cutoff } - - if score > best_cutoff_score - best_cutoff_score = score - best_cutoff = cutoff - end - end - - puts "Recommended ai_toxicity_flag_threshold_#{label} value: #{best_cutoff}" - end -end diff --git a/lib/topic_extensions.rb b/lib/topic_extensions.rb deleted file mode 100644 index 8f00edc3..00000000 --- a/lib/topic_extensions.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module TopicExtensions - extend ActiveSupport::Concern - - prepended do - has_many :ai_summaries, as: :target - - has_one :ai_gist_summary, - -> { where(summary_type: AiSummary.summary_types[:gist]) }, - class_name: "AiSummary", - as: :target - - has_many :inferred_concept_topics - has_many :inferred_concepts, through: :inferred_concept_topics - end - end -end diff --git a/lib/translation.rb b/lib/translation.rb deleted file mode 100644 index 09093f1c..00000000 --- a/lib/translation.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - def self.enabled? - SiteSetting.discourse_ai_enabled && SiteSetting.ai_translation_enabled && - SiteSetting.ai_translation_model.present? && - SiteSetting.content_localization_supported_locales.present? - end - - def self.backfill_enabled? - enabled? && SiteSetting.ai_translation_backfill_hourly_rate > 0 && - SiteSetting.ai_translation_backfill_max_age_days > 0 - end - end -end diff --git a/lib/translation/base_translator.rb b/lib/translation/base_translator.rb deleted file mode 100644 index 2ee3fb80..00000000 --- a/lib/translation/base_translator.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class BaseTranslator - def initialize(text:, target_locale:, topic: nil, post: nil) - @text = text - @target_locale = target_locale - @topic = topic - @post = post - end - - def translate - return nil if !SiteSetting.ai_translation_enabled - if (ai_persona = AiPersona.find_by(id: persona_setting)).blank? - return nil - end - translation_user = ai_persona.user || Discourse.system_user - persona_klass = ai_persona.class_instance - persona = persona_klass.new - - model = self.class.preferred_llm_model(persona_klass) - return nil if model.blank? - - bot = DiscourseAi::Personas::Bot.as(translation_user, persona:, model:) - - ContentSplitter - .split(content: @text, chunk_size: model.max_output_tokens) - .map { |text| get_translation(text:, bot:, translation_user:) } - .join("") - end - - private - - def formatted_content(content) - { content:, target_locale: @target_locale }.to_json - end - - def get_translation(text:, bot:, translation_user:) - context = - DiscourseAi::Personas::BotContext.new( - user: translation_user, - skip_tool_details: true, - feature_name: "translation", - messages: [{ type: :user, content: formatted_content(text) }], - topic: @topic, - post: @post, - ) - max_tokens = get_max_tokens(text) - llm_args = { max_tokens: } - - result = +"" - bot.reply(context, llm_args:) { |partial| result << partial } - result - end - - def get_max_tokens(text) - if text.length < 100 - 500 - elsif text.length < 500 - 1000 - else - text.length * 2 - end - end - - def persona_setting - raise NotImplementedError - end - - def self.preferred_llm_model(persona_klass) - id = persona_klass.default_llm_id || SiteSetting.ai_translation_model&.split(":")&.last - return nil if id.blank? - LlmModel.find_by(id:) - end - end - end -end diff --git a/lib/translation/category_locale_detector.rb b/lib/translation/category_locale_detector.rb deleted file mode 100644 index 544cf667..00000000 --- a/lib/translation/category_locale_detector.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class CategoryLocaleDetector - def self.detect_locale(category) - return if category.blank? - - text = [category.name, category.description].compact.join("\n\n") - return if text.blank? - - detected_locale = LanguageDetector.new(text).detect - locale = LocaleNormalizer.normalize_to_i18n(detected_locale) - category.update_column(:locale, locale) - locale - end - end - end -end diff --git a/lib/translation/category_localizer.rb b/lib/translation/category_localizer.rb deleted file mode 100644 index 0586f96c..00000000 --- a/lib/translation/category_localizer.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class CategoryLocalizer - def self.localize(category, target_locale = I18n.locale) - return if category.blank? || target_locale.blank? - - target_locale = target_locale.to_s.sub("-", "_") - - translated_name = ShortTextTranslator.new(text: category.name, target_locale:).translate - translated_description = - if category.description_excerpt.present? - PostRawTranslator.new(text: category.description_excerpt, target_locale:).translate - else - "" - end - - localization = - CategoryLocalization.find_or_initialize_by( - category_id: category.id, - locale: target_locale, - ) - - localization.name = translated_name - localization.description = translated_description - localization.save! - localization - end - end - end -end diff --git a/lib/translation/content_splitter.rb b/lib/translation/content_splitter.rb deleted file mode 100644 index 1885317e..00000000 --- a/lib/translation/content_splitter.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class ContentSplitter - DEFAULT_CHUNK_SIZE = 8192 - - BBCODE_PATTERNS = [ - %r{\[table.*?\].*?\[/table\]}m, - %r{\[quote.*?\].*?\[/quote\]}m, - %r{\[details.*?\].*?\[/details\]}m, - %r{\<details.*?\>.*?\</details\>}m, - %r{\[spoiler.*?\].*?\[/spoiler\]}m, - %r{\[code.*?\].*?\[/code\]}m, - /```.*?```/m, - ].freeze - - TEXT_BOUNDARIES = [ - /\n\s*\n\s*|\r\n\s*\r\n\s*/, # double newlines with optional spaces - /[.!?]\s+/, # sentence endings - /[,;]\s+/, # clause endings - /\n|\r\n/, # single newlines - /\s+/, # any whitespace - ].freeze - - def self.split(content:, chunk_size: DEFAULT_CHUNK_SIZE) - return [] if content.nil? - return [""] if content.empty? - chunk_size ||= DEFAULT_CHUNK_SIZE - return [content] if content.length <= chunk_size - - chunks = [] - remaining = content.dup - - while remaining.present? - chunk = extract_mixed_chunk(remaining, size: chunk_size) - break if chunk.empty? - chunks << chunk - remaining = remaining[chunk.length..-1] - end - - chunks - end - - private - - def self.extract_mixed_chunk(text, size:) - return text if text.length <= size - - # try each splitting strategy in order - split_point = - [ - -> { find_nearest_html_end_index(text, size) }, - -> { find_nearest_bbcode_end_index(text, size) }, - -> { find_text_boundary(text, size) }, - -> { size }, - ].lazy.map(&:call).compact.find { |pos| pos <= size } - - text[0...split_point] - end - - def self.find_nearest_html_end_index(text, target_pos) - return nil if !text.include?("<") - - begin - doc = Nokogiri::HTML5.fragment(text) - max_length_within_target = 0 - - doc.children.each do |node| - html = node.to_html - end_pos = max_length_within_target + html.length - if (max_length_within_target > 0 && end_pos > target_pos) - return max_length_within_target - end - max_length_within_target = end_pos - end - nil - rescue Nokogiri::SyntaxError - nil - end - end - - def self.find_nearest_bbcode_end_index(text, target_pos) - max_length_within_target = 0 - BBCODE_PATTERNS.each do |pattern| - text.scan(pattern) do |_| - match = $~ - tag_end = match.end(0) - if (max_length_within_target > 0 && tag_end > target_pos) - return max_length_within_target - end - max_length_within_target = tag_end - end - end - - nil - end - - def self.find_text_boundary(text, target_pos) - search_text = text - - TEXT_BOUNDARIES.each do |pattern| - if pos = search_text.rindex(pattern, target_pos) - # Include all trailing whitespace - pos += 1 while pos < search_text.length && search_text[pos].match?(/\s/) - return pos - end - end - nil - end - end - end -end diff --git a/lib/translation/entry_point.rb b/lib/translation/entry_point.rb deleted file mode 100644 index 68447209..00000000 --- a/lib/translation/entry_point.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class EntryPoint - def inject_into(plugin) - plugin.on(:post_created) do |post| - if DiscourseAi::Translation.enabled? - Jobs.enqueue(:detect_translate_post, post_id: post.id) - end - end - - plugin.on(:topic_created) do |topic| - if DiscourseAi::Translation.enabled? - Jobs.enqueue(:detect_translate_topic, topic_id: topic.id) - end - end - - plugin.on(:post_edited) do |post, topic_changed| - if DiscourseAi::Translation.enabled? && topic_changed - Jobs.enqueue(:detect_translate_topic, topic_id: post.topic_id) - end - end - end - end - end -end diff --git a/lib/translation/language_detector.rb b/lib/translation/language_detector.rb deleted file mode 100644 index 1cba37fc..00000000 --- a/lib/translation/language_detector.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class LanguageDetector - DETECTION_CHAR_LIMIT = 1000 - - def initialize(text, topic: nil, post: nil) - @text = text - @topic = topic - @post = post - end - - def detect - return nil if !SiteSetting.ai_translation_enabled - if ( - ai_persona = AiPersona.find_by(id: SiteSetting.ai_translation_locale_detector_persona) - ).blank? - return nil - end - - persona_klass = ai_persona.class_instance - persona = persona_klass.new - - llm_model = DiscourseAi::Translation::BaseTranslator.preferred_llm_model(persona_klass) - return nil if llm_model.blank? - - bot = - DiscourseAi::Personas::Bot.as( - ai_persona.user || Discourse.system_user, - persona: persona, - model: llm_model, - ) - - context = - DiscourseAi::Personas::BotContext.new( - user: ai_persona.user || Discourse.system_user, - skip_tool_details: true, - feature_name: "translation", - messages: [{ type: :user, content: @text }], - topic: @topic, - post: @post, - ) - - result = +"" - bot.reply(context) do |partial| - next if partial.strip.blank? - result << partial - end - result - end - end - end -end diff --git a/lib/translation/post_detection_text.rb b/lib/translation/post_detection_text.rb deleted file mode 100644 index c5dcc662..00000000 --- a/lib/translation/post_detection_text.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class PostDetectionText - NECESSARY_REMOVAL_SELECTORS = [ - ".lightbox-wrapper", # image captions - "blockquote, aside.quote", # quotes - ] - OPTIONAL_SELECTORS = [ - "a.hashtag-cooked", # categories or tags are usually in site's language - "a.mention", # mentions are based on the mentioned's user's name - "aside.onebox", # onebox external content - "img.emoji", - "code, pre", - ] - - def self.get_text(post) - return if post.blank? - cooked = post.cooked - return if cooked.blank? - - doc = Nokogiri::HTML5.fragment(cooked) - original = doc.text.strip - - # these selectors should be removed, - # as they are the usual culprits for incorrect detection - doc.css(*NECESSARY_REMOVAL_SELECTORS).remove - necessary = doc.text.strip - - doc.css(*OPTIONAL_SELECTORS).remove - preferred = doc.text.strip - - return preferred if preferred.present? - return necessary if necessary.present? - original - end - end - end -end diff --git a/lib/translation/post_locale_detector.rb b/lib/translation/post_locale_detector.rb deleted file mode 100644 index 74f625bf..00000000 --- a/lib/translation/post_locale_detector.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class PostLocaleDetector - def self.detect_locale(post) - return if post.blank? - - text = PostDetectionText.get_text(post) - detected_locale = LanguageDetector.new(text, post:).detect - locale = LocaleNormalizer.normalize_to_i18n(detected_locale) - post.update_column(:locale, locale) - locale - end - end - end -end diff --git a/lib/translation/post_localizer.rb b/lib/translation/post_localizer.rb deleted file mode 100644 index 0ca76d2b..00000000 --- a/lib/translation/post_localizer.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class PostLocalizer - def self.localize(post, target_locale = I18n.locale) - if post.blank? || target_locale.blank? || post.locale == target_locale.to_s || - post.raw.blank? - return - end - return if post.raw.length > SiteSetting.ai_translation_max_post_length - target_locale = target_locale.to_s.sub("-", "_") - - translated_raw = PostRawTranslator.new(text: post.raw, target_locale:, post:).translate - - localization = - PostLocalization.find_or_initialize_by(post_id: post.id, locale: target_locale) - - localization.raw = translated_raw - localization.cooked = PrettyText.cook(translated_raw) - localization.post_version = post.version - localization.localizer_user_id = Discourse.system_user.id - localization.save! - localization - end - end - end -end diff --git a/lib/translation/post_raw_translator.rb b/lib/translation/post_raw_translator.rb deleted file mode 100644 index 9c742b8e..00000000 --- a/lib/translation/post_raw_translator.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class PostRawTranslator < BaseTranslator - private - - def persona_setting - SiteSetting.ai_translation_post_raw_translator_persona - end - end - end -end diff --git a/lib/translation/short_text_translator.rb b/lib/translation/short_text_translator.rb deleted file mode 100644 index 99a71c1c..00000000 --- a/lib/translation/short_text_translator.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class ShortTextTranslator < BaseTranslator - private - - def persona_setting - SiteSetting.ai_translation_short_text_translator_persona - end - end - end -end diff --git a/lib/translation/topic_locale_detector.rb b/lib/translation/topic_locale_detector.rb deleted file mode 100644 index 81ac3025..00000000 --- a/lib/translation/topic_locale_detector.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class TopicLocaleDetector - def self.detect_locale(topic) - return if topic.blank? - - detected_locale = LanguageDetector.new(topic.title.dup, topic:).detect - locale = LocaleNormalizer.normalize_to_i18n(detected_locale) - topic.update_column(:locale, locale) - locale - end - end - end -end diff --git a/lib/translation/topic_localizer.rb b/lib/translation/topic_localizer.rb deleted file mode 100644 index 19253518..00000000 --- a/lib/translation/topic_localizer.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class TopicLocalizer - def self.localize(topic, target_locale = I18n.locale) - return if topic.blank? || target_locale.blank? || topic.locale == target_locale.to_s - - target_locale = target_locale.to_s.sub("-", "_") - - translated_title = - TopicTitleTranslator.new(text: topic.title, target_locale:, topic:).translate - translated_excerpt = - PostRawTranslator.new(text: topic.excerpt, target_locale:, topic:).translate - - localization = - TopicLocalization.find_or_initialize_by(topic_id: topic.id, locale: target_locale) - - localization.title = translated_title - localization.fancy_title = Topic.fancy_title(translated_title) - localization.excerpt = translated_excerpt - localization.localizer_user_id = Discourse.system_user.id - localization.save! - localization - end - end - end -end diff --git a/lib/translation/topic_title_translator.rb b/lib/translation/topic_title_translator.rb deleted file mode 100644 index c611f066..00000000 --- a/lib/translation/topic_title_translator.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class TopicTitleTranslator < BaseTranslator - private - - def persona_setting - SiteSetting.ai_translation_topic_title_translator_persona - end - end - end -end diff --git a/lib/translation/verbose_logger.rb b/lib/translation/verbose_logger.rb deleted file mode 100644 index 2df0ab63..00000000 --- a/lib/translation/verbose_logger.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Translation - class VerboseLogger - def self.log(message, opts = { level: :warn }) - if SiteSetting.ai_translation_verbose_logs - Rails.logger.send(opts[:level], "DiscourseAi::Translation: #{message}") - end - end - end - end -end diff --git a/lib/utils/ai_staff_action_logger.rb b/lib/utils/ai_staff_action_logger.rb deleted file mode 100644 index f6ff4213..00000000 --- a/lib/utils/ai_staff_action_logger.rb +++ /dev/null @@ -1,206 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Utils - class AiStaffActionLogger - ## Maximum length for text fields before truncation/simplification - MAX_TEXT_LENGTH = 100 - - def initialize(current_user) - @current_user = current_user - @staff_logger = ::StaffActionLogger.new(current_user) - end - - ## Logs the creation of an AI entity (LLM model or persona) - ## @param entity_type [Symbol] The type of AI entity being created - ## @param entity [Object] The entity object being created - ## @param field_config [Hash] Configuration for how to handle different entity fields - ## @param entity_details [Hash] Additional details about the entity to be logged - def log_creation(entity_type, entity, field_config = {}, entity_details = {}) - # Start with provided entity details (id, name, etc.) - # Convert all keys to strings for consistent handling in StaffActionLogger - log_details = {} - - # Extract subject for StaffActionLogger.base_attrs - subject = - entity_details[:subject] || - (entity.respond_to?(:display_name) ? entity.display_name : nil) - - # Add the entity details but preserve subject as a top-level attribute - entity_details.each { |k, v| log_details[k.to_s] = v unless k == :subject } - - # Extract attributes based on field configuration and ensure string keys - extract_entity_attributes(entity, field_config).each do |key, value| - log_details[key.to_s] = value - end - - @staff_logger.log_custom("create_ai_#{entity_type}", log_details.merge(subject: subject)) - end - - ## Logs an update to an AI entity with before/after comparison - ## @param entity_type [Symbol] The type of AI entity being updated - ## @param entity [Object] The entity object after update - ## @param initial_attributes [Hash] The attributes of the entity before update - ## @param field_config [Hash] Configuration for how to handle different entity fields - ## @param entity_details [Hash] Additional details about the entity to be logged - def log_update( - entity_type, - entity, - initial_attributes, - field_config = {}, - entity_details = {} - ) - current_attributes = entity.attributes - changes = {} - - # Process changes based on field configuration - field_config - .except(:json_fields) - .each do |field, options| - # Skip if field is not to be tracked - next if options[:track] == false - - initial_value = initial_attributes[field.to_s] - current_value = current_attributes[field.to_s] - - # Only process if there's an actual change - if initial_value != current_value - # Format the change based on field type - changes[field.to_s] = format_field_change( - field, - initial_value, - current_value, - options, - ) - end - end - - # Process simple JSON fields (arrays, hashes) that should be tracked as "updated" - if field_config[:json_fields].present? - field_config[:json_fields].each do |field| - field_str = field.to_s - if initial_attributes[field_str].to_s != current_attributes[field_str].to_s - changes[field_str] = I18n.t("discourse_ai.ai_staff_action_logger.updated") - end - end - end - - # Only log if there are actual changes - if changes.any? - # Extract subject for StaffActionLogger.base_attrs - subject = - entity_details[:subject] || - (entity.respond_to?(:display_name) ? entity.display_name : nil) - - log_details = {} - # Convert entity_details keys to strings, but preserve subject as a top-level attribute - entity_details.each { |k, v| log_details[k.to_s] = v unless k == :subject } - # Merge changes (already with string keys) - log_details.merge!(changes) - - @staff_logger.log_custom("update_ai_#{entity_type}", log_details.merge(subject: subject)) - end - end - - ## Logs the deletion of an AI entity - ## @param entity_type [Symbol] The type of AI entity being deleted - ## @param entity_details [Hash] Details about the entity being deleted - def log_deletion(entity_type, entity_details) - # Extract subject for StaffActionLogger.base_attrs - subject = entity_details[:subject] - - # Convert all keys to strings for consistent handling in StaffActionLogger - string_details = {} - entity_details.each { |k, v| string_details[k.to_s] = v unless k == :subject } - - @staff_logger.log_custom("delete_ai_#{entity_type}", string_details.merge(subject: subject)) - end - - ## Direct custom logging for complex cases - ## @param action_type [String] The type of action being logged - ## @param log_details [Hash] Details to be logged - def log_custom(action_type, log_details) - # Extract subject for StaffActionLogger.base_attrs if present - subject = log_details[:subject] - - # Convert all keys to strings for consistent handling in StaffActionLogger - string_details = {} - log_details.each { |k, v| string_details[k.to_s] = v unless k == :subject } - - @staff_logger.log_custom(action_type, string_details.merge(subject: subject)) - end - - private - - ## Formats the change in a field's value for logging - ## @param field [Symbol] The field that changed - ## @param initial_value [Object] The original value - ## @param current_value [Object] The new value - ## @param options [Hash] Options for formatting - ## @return [String] Formatted representation of the change - def format_field_change(field, initial_value, current_value, options = {}) - if options[:type] == :sensitive - return format_sensitive_field_change(initial_value, current_value) - elsif options[:type] == :large_text || - (initial_value.is_a?(String) && initial_value.length > MAX_TEXT_LENGTH) || - (current_value.is_a?(String) && current_value.length > MAX_TEXT_LENGTH) - return I18n.t("discourse_ai.ai_staff_action_logger.updated") - end - - # Default formatting: "old_value → new_value" - "#{initial_value} → #{current_value}" - end - - ## Formats changes to sensitive fields without exposing actual values - ## @param initial_value [Object] The original value - ## @param current_value [Object] The new value - ## @return [String] Description of the change (updated/set/removed) - def format_sensitive_field_change(initial_value, current_value) - if initial_value.present? && current_value.present? - I18n.t("discourse_ai.ai_staff_action_logger.updated") - elsif current_value.present? - I18n.t("discourse_ai.ai_staff_action_logger.set") - else - I18n.t("discourse_ai.ai_staff_action_logger.removed") - end - end - - ## Extracts relevant attributes from an entity based on field configuration - ## @param entity [Object] The entity to extract attributes from - ## @param field_config [Hash] Configuration for how to handle different entity fields - ## @return [Hash] The extracted attributes - def extract_entity_attributes(entity, field_config) - result = {} - field_config.each do |field, options| - # Skip special keys like :json_fields which are arrays, not field configurations - next if field == :json_fields - - # Skip if options is not a hash or if explicitly marked as not to be extracted - next if !options.is_a?(Hash) || options[:extract] == false - - # Get the actual field value - field_sym = field.to_sym - value = entity.respond_to?(field_sym) ? entity.public_send(field_sym) : nil - - # Apply field-specific handling - if options[:type] == :sensitive - result[field] = value.present? ? "[FILTERED]" : nil - elsif options[:type] == :large_text && value.is_a?(String) && - value.length > MAX_TEXT_LENGTH - result[field] = value.truncate(MAX_TEXT_LENGTH) - else - result[field] = value - end - end - - # Always include dimensions if it exists on the entity - # This is important for embeddings which are tested for dimensions value - if entity.respond_to?(:dimensions) && !result.key?(:dimensions) - result[:dimensions] = entity.dimensions - end - - result - end - end - end -end diff --git a/lib/utils/best_effort_json_parser.rb b/lib/utils/best_effort_json_parser.rb deleted file mode 100644 index c2f2f2bf..00000000 --- a/lib/utils/best_effort_json_parser.rb +++ /dev/null @@ -1,137 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module DiscourseAi - module Utils - class BestEffortJsonParser - class << self - def extract_key(helper_response, schema_type, schema_key) - return helper_response unless helper_response.is_a?(String) - - schema_type = schema_type.to_sym - schema_key = schema_key&.to_sym - cleaned = remove_markdown_fences(helper_response.strip) - - parsed = - try_parse(cleaned) || try_parse(fix_common_issues(cleaned)) || - manual_extract(cleaned, schema_key, schema_type) - - value = parsed.is_a?(Hash) ? parsed[schema_key.to_s] : parsed - - cast_value(value, schema_type) - end - - private - - def remove_markdown_fences(text) - return text unless text.match?(/^```(?:json)?\s*\n/i) - - text.gsub(/^```(?:json)?\s*\n/i, "").gsub(/\n```\s*$/, "") - end - - def fix_common_issues(text) - text.gsub(/(\w+):/, '"\1":').gsub(/'/, "\"") - end - - def try_parse(text) - JSON.parse(text) - rescue JSON::ParserError - nil - end - - def manual_extract(text, key, schema_type) - return default_for(schema_type) unless key - - case schema_type - when :object - extract_object(text, key.to_s) - when :array, :string - extract_scalar(text, key.to_s, schema_type) - else - default_for(schema_type) - end - end - - def extract_scalar(text, key, schema_type) - patterns = - if schema_type == :array - [ - /"#{key}"\s*:\s*\[([^\]]+)\]/, - /'#{key}'\s*:\s*\[([^\]]+)\]/, - /#{key}\s*:\s*\[([^\]]+)\]/, - ] - else - [ - /"#{key}"\s*:\s*"([^"]+)"/, - /'#{key}'\s*:\s*'([^']+)'/, - /#{key}\s*:\s*"([^"]+)"/, - /#{key}\s*:\s*'([^']+)'/, - ] - end - - patterns.each do |pattern| - match = text.match(pattern) - next unless match - - value = match[1] - return schema_type == :array ? parse_array(value) : value - end - - default_for(schema_type) - end - - def parse_array(value) - JSON.parse("[#{value}]") - rescue JSON::ParserError - value.split(",").map { |item| item.strip.gsub(/^['"]|['"]$/, "") } - end - - def extract_object(text, key) - pattern = /("#{key}"|'#{key}'|#{key})\s*:\s*\{/ - match = text.match(pattern) or return {} - - start = match.end(0) - 1 - return {} unless text[start] == "{" - - end_pos = find_matching_brace(text, start) - return {} unless end_pos - - obj_str = text[start..end_pos] - try_parse(obj_str) || try_parse(fix_common_issues(obj_str)) || {} - end - - def find_matching_brace(text, start_pos) - brace_count = 0 - - text[start_pos..-1].each_char.with_index do |char, idx| - brace_count += 1 if char == "{" - if char == "}" - brace_count -= 1 - return start_pos + idx if brace_count.zero? - end - end - nil - end - - def cast_value(value, schema_type) - case schema_type - when :array - value.is_a?(Array) ? value : [] - when :object - value.is_a?(Hash) ? value : {} - when :boolean - return value if [true, false, nil].include?(value) - value.to_s.downcase == "true" - else - value.to_s - end - end - - def default_for(schema_type) - schema_type == :array ? [] : schema_type == :object ? {} : "" - end - end - end - end -end diff --git a/lib/utils/diff_utils/hunk_diff.rb b/lib/utils/diff_utils/hunk_diff.rb deleted file mode 100644 index dbe6743a..00000000 --- a/lib/utils/diff_utils/hunk_diff.rb +++ /dev/null @@ -1,228 +0,0 @@ -# frozen_string_literal: true -# Inspired by Aider https://github.com/Aider-AI/aider - -module DiscourseAi - module Utils - module DiffUtils - class HunkDiff - class DiffError < StandardError - attr_reader :original_text, :diff_text, :context - - def initialize(message, original_text:, diff_text:, context: {}) - @original_text = original_text - @diff_text = diff_text - @context = context - super(message) - end - - def to_llm_message - original_text = @original_text - original_text = @original_text[0..1000] + "..." if @original_text.length > 1000 - - <<~MESSAGE - #{message} - - Original text: - ``` - #{original_text} - ``` - - Attempted diff: - ``` - #{diff_text} - ``` - - #{context_message} - - Please provide a corrected diff that: - 1. Has the correct context lines - 2. Contains all necessary removals (-) and additions (+) - MESSAGE - end - - private - - def context_message - return "" if context.empty? - - context.map { |key, value| "#{key}: #{value}" }.join("\n") - end - end - - class NoMatchingContextError < DiffError - def initialize(original_text:, diff_text:) - super( - "Could not find the context lines in the original text", - original_text: original_text, - diff_text: diff_text, - ) - end - end - - class AmbiguousMatchError < DiffError - def initialize(original_text:, diff_text:) - super( - "Found multiple possible locations for this change", - original_text: original_text, - diff_text: diff_text, - ) - end - end - - class MalformedDiffError < DiffError - def initialize(original_text:, diff_text:, issue:) - super( - "The diff format is invalid", - original_text: original_text, - diff_text: diff_text, - context: { - "Issue" => issue, - }, - ) - end - end - - def self.apply(text, diff) - new(text, diff).apply - end - - def initialize(text, diff) - @text = text.encode(universal_newline: true) - @diff = diff.encode(universal_newline: true) - @text = @text + "\n" unless @text.end_with?("\n") - end - - def apply - if multiple_hunks? - apply_multiple_hunks - else - apply_single_hunk - end - end - - private - - attr_reader :text, :diff - - def multiple_hunks? - diff.match?(/^\@\@.*\@\@$\n/) - end - - def apply_multiple_hunks - result = text - hunks = diff.split(/^\@\@.*\@\@$\n/) - - hunks.each do |hunk| - next if hunk.blank? - result = self.class.new(result, hunk).apply - end - - result - end - - def apply_single_hunk - diff_lines = parse_diff_lines - validate_diff_format!(diff_lines) - - return text.strip + "\n" + diff.strip if context_only?(diff_lines) - - lines_to_match = extract_context_lines(diff_lines) - match_start, match_end = find_unique_match(lines_to_match) - - build_result(match_start, match_end, diff_lines) - end - - def parse_diff_lines - diff.lines.map do |line| - marker = line[0] - content = line[1..] - - if !["-", "+", " "].include?(marker) - marker = " " - content = line - end - - [marker, content] - end - end - - def validate_diff_format!(diff_lines) - if diff_lines.empty? - raise MalformedDiffError.new( - original_text: text, - diff_text: diff, - issue: "Diff is empty", - ) - end - end - - def context_only?(diff_lines) - diff_lines.all? { |marker, _| marker == " " } - end - - def extract_context_lines(diff_lines) - diff_lines.select { |marker, _| ["-", " "].include?(marker) }.map(&:last) - end - - def find_unique_match(context_lines) - return 0, 0 if context_lines.empty? - - pattern = context_lines.map { |line| "^\\s*" + Regexp.escape(line.strip) + "\s*$\n" }.join - matches = - text - .enum_for(:scan, /#{pattern}/m) - .map do - match = Regexp.last_match - [match.begin(0), match.end(0)] - end - - case matches.length - when 0 - raise NoMatchingContextError.new(original_text: text, diff_text: diff) - when 1 - matches.first - else - raise AmbiguousMatchError.new(original_text: text, diff_text: diff) - end - end - - def build_result(match_start, match_end, diff_lines) - new_hunk = +"" - diff_lines_index = 0 - - text[match_start..match_end].lines.each do |line| - diff_marker, diff_content = diff_lines[diff_lines_index] - - while diff_marker == "+" - new_hunk << diff_content - diff_lines_index += 1 - diff_marker, diff_content = diff_lines[diff_lines_index] - end - - new_hunk << line if diff_marker == " " - - diff_lines_index += 1 - end - - # Handle any remaining additions - append_remaining_additions(new_hunk, diff_lines, diff_lines_index) - - combine_result(match_start, match_end, new_hunk) - end - - def append_remaining_additions(new_hunk, diff_lines, diff_lines_index) - diff_marker, diff_content = diff_lines[diff_lines_index] - while diff_marker == "+" - diff_lines_index += 1 - new_hunk << diff_content - diff_marker, diff_content = diff_lines[diff_lines_index] - end - end - - def combine_result(match_start, match_end, new_hunk) - (text[0...match_start].to_s + new_hunk + text[match_end..-1].to_s).strip - end - end - end - end -end diff --git a/lib/utils/diff_utils/safety_checker.rb b/lib/utils/diff_utils/safety_checker.rb deleted file mode 100644 index 16adb3ab..00000000 --- a/lib/utils/diff_utils/safety_checker.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -require "cgi" - -module DiscourseAi - module Utils - module DiffUtils - class SafetyChecker - def self.safe_to_stream?(html_text) - new(html_text).safe? - end - - def initialize(html_text) - @original_html = html_text - @text = sanitize(html_text) - end - - def safe? - return false if unclosed_markdown_links? - return false if unclosed_raw_html_tag? - return false if trailing_incomplete_url? - return false if unclosed_backticks? - return false if unbalanced_bold_or_italic? - return false if incomplete_image_markdown? - return false if unbalanced_quote_blocks? - return false if unclosed_triple_backticks? - return false if partial_emoji? - - true - end - - private - - def sanitize(html) - text = html.gsub(%r{</?[^>]+>}, "") # remove tags like <span>, <del>, etc. - CGI.unescapeHTML(text) - end - - def unclosed_markdown_links? - open_brackets = @text.count("[") - close_brackets = @text.count("]") - open_parens = @text.count("(") - close_parens = @text.count(")") - - open_brackets != close_brackets || open_parens != close_parens - end - - def unclosed_raw_html_tag? - last_lt = @text.rindex("<") - last_gt = @text.rindex(">") - last_lt && (!last_gt || last_gt < last_lt) - end - - def trailing_incomplete_url? - last_word = @text.split(/\s/).last - last_word =~ %r{\Ahttps?://[^\s]*\z} && last_word !~ /[)\].,!?:;'"]\z/ - end - - def unclosed_backticks? - @text.count("`").odd? - end - - def unbalanced_bold_or_italic? - @text.scan(/\*\*/).count.odd? || @text.scan(/\*(?!\*)/).count.odd? || - @text.scan(/_/).count.odd? - end - - def incomplete_image_markdown? - last_image = @text[/!\[.*?\]\(.*?$/, 0] - last_image && last_image[-1] != ")" - end - - def unbalanced_quote_blocks? - opens = @text.scan(/\[quote(=.*?)?\]/i).count - closes = @text.scan(%r{\[/quote\]}i).count - opens > closes - end - - def unclosed_triple_backticks? - @text.scan(/```/).count.odd? - end - - def partial_emoji? - text = @text.gsub(/!\[.*?\]\(.*?\)/, "").gsub(%r{https?://[^\s]+}, "") - tokens = text.scan(/:[a-z0-9_+\-\.]+:?/i) - tokens.any? { |token| token.start_with?(":") && !token.end_with?(":") } - end - end - end - end -end diff --git a/lib/utils/diff_utils/simple_diff.rb b/lib/utils/diff_utils/simple_diff.rb deleted file mode 100644 index 202ea881..00000000 --- a/lib/utils/diff_utils/simple_diff.rb +++ /dev/null @@ -1,214 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Utils - module DiffUtils - class SimpleDiff - LEVENSHTEIN_THRESHOLD = 2 - - class Error < StandardError - end - class NoMatchError < Error - end - - def self.apply(content, search, replace) - new.apply(content, search, replace) - end - - def apply(content, search, replace) - raise ArgumentError, "content cannot be nil" if content.nil? - raise ArgumentError, "search cannot be nil" if search.nil? - raise ArgumentError, "replace cannot be nil" if replace.nil? - raise ArgumentError, "search cannot be empty" if search.empty? - - return content.gsub(search, replace) if content.include?(search) - - lines = content.split("\n") - search_lines = search.split("\n") - - ### TODO implement me - - # 1. Try exact matching - match_positions = - find_matches(lines, search_lines) { |line, search_line| line == search_line } - - # 2. Try stripped matching - if match_positions.empty? - match_positions = - find_matches(lines, search_lines) do |line, search_line| - line.strip == search_line.strip - end - end - - # 3. Try non-contiguous line based stripped matching - if match_positions.empty? - if range = non_contiguous_match_range(lines, search_lines) - first_match, last_match = range - lines.slice!(first_match, last_match - first_match + 1) - lines.insert(first_match, *replace.split("\n")) - return lines.join("\n") - end - end - - # 4. Try fuzzy matching - if match_positions.empty? - match_positions = - find_matches(lines, search_lines) do |line, search_line| - fuzzy_match?(line, search_line) - end - end - - # 5. Try block matching as last resort - if match_positions.empty? - if block_matches = find_block_matches(content, search) - return replace_blocks(content, block_matches, replace) - end - end - - if match_positions.empty? - raise NoMatchError, "Could not find a match for the search content" - end - - # Replace matches in reverse order - match_positions.sort.reverse.each do |pos| - lines.slice!(pos, search_lines.length) - lines.insert(pos, *replace.split("\n")) - end - - lines.join("\n") - end - - private - - def non_contiguous_match_range(lines, search_lines) - first_idx = nil - last_idx = nil - search_index = 0 - - lines.each_with_index do |line, idx| - if search_lines[search_index].strip == "..." - search_index += 1 - break if search_lines[search_index].nil? - end - if line.strip == search_lines[search_index].strip - first_idx ||= idx - last_idx = idx - search_index += 1 - return first_idx, last_idx if search_index == search_lines.length - end - end - - nil - end - - def find_matches(lines, search_lines) - matches = [] - max_index = lines.length - search_lines.length - - (0..max_index).each do |i| - if (0...search_lines.length).all? { |j| yield(lines[i + j], search_lines[j]) } - matches << i - end - end - - matches - end - - def fuzzy_match?(line, search_line) - return true if line.strip == search_line.strip - s1 = line.lstrip - s2 = search_line.lstrip - levenshtein_distance(s1, s2) <= LEVENSHTEIN_THRESHOLD - end - - def levenshtein_distance(s1, s2) - m = s1.length - n = s2.length - d = Array.new(m + 1) { Array.new(n + 1, 0) } - - (0..m).each { |i| d[i][0] = i } - (0..n).each { |j| d[0][j] = j } - - (1..m).each do |i| - (1..n).each do |j| - cost = s1[i - 1] == s2[j - 1] ? 0 : 1 - d[i][j] = [d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost].min - end - end - - d[m][n] - end - - def find_block_matches(content, search) - content_blocks = extract_blocks(content) - search_blocks = extract_blocks(search) - - return nil if content_blocks.empty? || search_blocks.empty? - - matches = [] - search_blocks.each do |search_block| - content_blocks.each do |content_block| - matches << content_block if content_block[:text] == search_block[:text] - end - end - - matches.empty? ? nil : matches - end - - def extract_blocks(text) - lines = text.split("\n") - blocks = [] - current_block = [] - block_start = nil - - lines.each_with_index do |line, index| - if line =~ /^[^\s]/ - # Save previous block if exists - if !current_block.empty? - current_block << line - blocks << { - start: block_start, - length: current_block.length, - text: current_block.join("\n").strip, - } - current_block = [] - else - current_block = [line] - block_start = index - end - else - # Continue current block - current_block << line if current_block.any? - end - end - - # Add final block - if !current_block.empty? - blocks << { - start: block_start, - length: current_block.length, - text: current_block.join("\n").strip, - } - end - - blocks - end - - def replace_blocks(content, blocks, replace) - lines = content.split("\n") - - # Sort blocks in reverse order to maintain correct positions - blocks - .sort_by { |b| -b[:start] } - .each_with_index do |block, index| - replacement = index.zero? ? replace : "" - lines.slice!(block[:start], block[:length]) - lines.insert(block[:start], *replacement.split("\n")) - end - - lines.join("\n") - end - end - end - end -end diff --git a/lib/utils/dns_srv.rb b/lib/utils/dns_srv.rb deleted file mode 100644 index 502d68d9..00000000 --- a/lib/utils/dns_srv.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require "resolv" - -module DiscourseAi - module Utils - module DnsSrv - def self.lookup(domain) - Discourse - .cache - .fetch("dns_srv_lookup:#{domain}", expires_in: 5.minutes) do - resources = dns_srv_lookup_for_domain(domain) - - server_election(resources) - end - end - - private - - def self.dns_srv_lookup_for_domain(domain) - resolver = Resolv::DNS.new - resolver.getresources(domain, Resolv::DNS::Resource::IN::SRV) - end - - def self.select_server(resources) - priority = resources.group_by(&:priority).keys.min - - priority_resources = resources.select { |r| r.priority == priority } - - total_weight = priority_resources.map(&:weight).sum - - random_weight = rand(total_weight) - - priority_resources.each do |resource| - random_weight -= resource.weight - - return resource if random_weight < 0 - end - end - - def self.server_available?(server) - begin - conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - conn.head("https://#{server.target}:#{server.port}") - true - rescue StandardError - false - end - end - - def self.server_election(resources) - return nil if resources.empty? - return resources.first if resources.length == 1 - - candidate = select_server(resources) - - if server_available?(candidate) - candidate - else - server_election(resources - [candidate]) - end - end - end - end -end diff --git a/lib/utils/image_to_text.rb b/lib/utils/image_to_text.rb deleted file mode 100644 index e3400388..00000000 --- a/lib/utils/image_to_text.rb +++ /dev/null @@ -1,232 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Utils::ImageToText - BACKOFF_SECONDS = [5, 30, 60] - MAX_IMAGE_SIZE = 10.megabytes - - class Reader - def initialize(uploads:, llm_model:, user:) - @uploads = uploads - @llm_model = llm_model - @user = user - @buffer = +"" - - @to_process = uploads.dup - end - - # return nil if no more data - def read(length) - # for implementation simplicity we will process one image at a time - if !@buffer.empty? - part = @buffer.slice!(0, length) - return part - end - - return nil if @to_process.empty? - - upload = @to_process.shift - extractor = - DiscourseAi::Utils::ImageToText.new(upload: upload, llm_model: @llm_model, user: @user) - extractor.extract_text do |chunk, error| - if error - Discourse.warn_exception( - error, - message: "Discourse AI: Failed to extract text from image", - ) - else - # this introduces chunk markers so discourse rag ingestion requires no overlaps - @buffer << "\n[[metadata ]]\n" - @buffer << chunk - end - end - - read(length) - end - end - - def self.as_fake_file(uploads:, llm_model:, user:) - # given our implementation for extracting text expect a file, return a simple object that can simulate read(size) - # and stream content - Reader.new(uploads: uploads, llm_model: llm_model, user: user) - end - - def self.tesseract_installed? - if defined?(@tesseract_installed) - @tesseract_installed - else - @tesseract_installed = - begin - Discourse::Utils.execute_command("which", "tesseract") - true - rescue Discourse::Utils::CommandError - false - end - end - end - - attr_reader :upload, :llm_model, :user - - def initialize(upload:, llm_model:, user:, guidance_text: nil) - @upload = upload - @llm_model = llm_model - @user = user - @guidance_text = guidance_text - end - - def extract_text(retries: 3) - uploads ||= @uploaded_pages - - raise "must specify a block" if !block_given? - extracted = nil - error = nil - - backoff = BACKOFF_SECONDS.dup - - retries.times do - seconds = nil - begin - extracted = extract_text_from_page(upload) - break - rescue => e - error = e - seconds = backoff.shift || seconds - sleep(seconds) - end - end - if extracted - extracted.each { |chunk| yield(chunk) } - else - yield(nil, error) - end - extracted || [] - end - - private - - def system_message - <<~MSG - OCR the following page into Markdown. Tables should be formatted as Github flavored markdown. - Do not surround your output with triple backticks. - - Chunk the document into sections of roughly 250 - 1000 words. Our goal is to identify parts of the page with same semantic theme. These chunks will be embedded and used in a RAG pipeline. - - Always prefer returning text in Markdown vs HTML. - Describe all the images and graphs you encounter. - Only return text that will assist in the querying of data. Omit text such as "I had trouble recognizing images" and so on. - - Surround the chunks with <chunk> </chunk> html tags. - MSG - end - - def extract_text_from_page(page) - raw_text = @guidance_text - raw_text ||= extract_text_with_tesseract(page) if self.class.tesseract_installed? - - llm = llm_model.to_llm - if raw_text.present? - messages = [ - { - type: :user, - content: [ - "The following text was extracted from an image using OCR. Please enhance, correct, and structure this content while maintaining the original text:\n\n#{raw_text}", - { upload_id: page.id }, - ], - }, - ] - else - messages = [ - { type: :user, content: ["Please OCR the content in the image.", { upload_id: page.id }] }, - ] - end - prompt = DiscourseAi::Completions::Prompt.new(system_message, messages: messages) - result = llm.generate(prompt, user: Discourse.system_user) - extract_chunks(result) - end - - def extract_text_with_tesseract(page) - # return nil if we can not find tessaract binary - return nil if !self.class.tesseract_installed? - upload_path = - if page.local? - Discourse.store.path_for(page) - else - Discourse.store.download_safe(page, max_file_size_kb: MAX_IMAGE_SIZE)&.path - end - - return "" if !upload_path || !File.exist?(upload_path) - - tmp_output_file = Tempfile.new(%w[tesseract_output .txt]) - tmp_output = tmp_output_file.path - tmp_output_file.unlink - - command = [ - "tesseract", - upload_path, - tmp_output.sub(/\.txt$/, ""), # Tesseract adds .txt automatically - ] - - success = - Discourse::Utils.execute_command( - *command, - timeout: 20.seconds, - failure_message: "Failed to OCR image with Tesseract", - ) - - if success && File.exist?("#{tmp_output}") - text = File.read("#{tmp_output}") - begin - File.delete("#{tmp_output}") - rescue StandardError - nil - end - text.strip - else - Rails.logger.error("Tesseract OCR failed for #{upload_path}") - "" - end - rescue => e - Rails.logger.error("Error during OCR processing: #{e.message}") - "" - end - - def extract_chunks(text) - return [] if text.nil? || text.empty? - - if text.include?("<chunk>") && text.include?("</chunk>") - chunks = [] - remaining_text = text.dup - - while remaining_text.length > 0 - if remaining_text.start_with?("<chunk>") - # Extract chunk content - chunk_end = remaining_text.index("</chunk>") - if chunk_end - chunk = remaining_text[7..chunk_end - 1].strip - chunks << chunk unless chunk.empty? - remaining_text = remaining_text[chunk_end + 8..-1] || "" - else - # Malformed chunk - add remaining text and break - chunks << remaining_text[7..-1].strip - break - end - else - # Handle text before next chunk if it exists - next_chunk = remaining_text.index("<chunk>") - if next_chunk - text_before = remaining_text[0...next_chunk].strip - chunks << text_before unless text_before.empty? - remaining_text = remaining_text[next_chunk..-1] - else - # No more chunks - add remaining text and break - chunks << remaining_text.strip - break - end - end - end - - return chunks.reject(&:empty?) - end - - [text] - end -end diff --git a/lib/utils/pdf_to_images.rb b/lib/utils/pdf_to_images.rb deleted file mode 100644 index 0411ab7a..00000000 --- a/lib/utils/pdf_to_images.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Utils::PdfToImages - MAX_PDF_SIZE = 100.megabytes - # this is long, mutool can be faster than magick, 10 minutes will be enough for quite large pdfs - MAX_CONVERT_SECONDS = 600 - BACKOFF_SECONDS = [5, 30, 60] - - attr_reader :upload, :user - - def initialize(upload:, user:) - @upload = upload - @user = user - @uploaded_pages = UploadReference.where(target: upload).map(&:upload).presence - end - - def uploaded_pages - @uploaded_pages ||= extract_pages - end - - def extract_pages - begin - pdf_path = - if upload.local? - Discourse.store.path_for(upload) - else - Discourse.store.download_safe(upload, max_file_size_kb: MAX_PDF_SIZE)&.path - end - - raise Discourse::InvalidParameters.new("Failed to download PDF") if pdf_path.nil? - - temp_dir = Dir.mktmpdir("discourse-pdf-#{SecureRandom.hex(8)}") - temp_pdf = File.join(temp_dir, "source.pdf") - FileUtils.cp(pdf_path, temp_pdf) - - # Convert PDF to individual page images - output_pattern = File.join(temp_dir, "page-%04d.png") - - command = [ - "magick", - "-density", - "300", - temp_pdf, - "-background", - "white", - "-auto-orient", - "-quality", - "85", - output_pattern, - ] - - Discourse::Utils.execute_command( - *command, - failure_message: "Failed to convert PDF to images", - timeout: MAX_CONVERT_SECONDS, - ) - - uploads = [] - Dir - .glob(File.join(temp_dir, "page-*.png")) - .sort - .each do |page_path| - upload = - UploadCreator.new(File.open(page_path), "page-#{File.basename(page_path)}").create_for( - @user.id, - ) - - uploads << upload - end - - # Create upload references - UploadReference.ensure_exist!(upload_ids: uploads.map(&:id), target: @upload) - - @uploaded_pages = uploads - ensure - FileUtils.rm_rf(temp_dir) if temp_dir - end - end -end diff --git a/lib/utils/pdf_to_text.rb b/lib/utils/pdf_to_text.rb deleted file mode 100644 index 9acd2b89..00000000 --- a/lib/utils/pdf_to_text.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -class DiscourseAi::Utils::PdfToText - MAX_PDF_SIZE = 100.megabytes - - class Reader - def initialize(upload:, user: nil, llm_model: nil) - @extractor = - DiscourseAi::Utils::PdfToText.new(upload: upload, user: user, llm_model: llm_model) - @enumerator = create_enumerator - @buffer = +"" - end - - def read(length) - return @buffer.slice!(0, length) if !@buffer.empty? - - begin - @buffer << @enumerator.next - rescue StopIteration - return nil - end - - @buffer.slice!(0, length) - end - - private - - def create_enumerator - Enumerator.new { |yielder| @extractor.extract_text { |chunk| yielder.yield(chunk || "") } } - end - end - - attr_reader :upload - - def self.as_fake_file(upload:, user: nil, llm_model: nil) - Reader.new(upload: upload, user: user, llm_model: llm_model) - end - - def initialize(upload:, user: nil, llm_model: nil) - @upload = upload - @user = user - @llm_model = llm_model - end - - def extract_text - pdf_path = - if upload.local? - Discourse.store.path_for(upload) - else - Discourse.store.download_safe(upload, max_file_size_kb: MAX_PDF_SIZE)&.path - end - - raise Discourse::InvalidParameters.new("Failed to download PDF") if pdf_path.nil? - - require "pdf/reader" - - page_number = 0 - PDF::Reader.open(pdf_path) do |reader| - reader.pages.each do |page| - page_number += 1 - llm_decorate(page_number: page_number, text: page.text, pdf_path: pdf_path) do |chunk| - yield chunk - end - end - end - end - - def llm_decorate(page_number:, text:, pdf_path:) - raise "Must be called with block" if !block_given? - if !@llm_model - yield text - return - end - - begin - temp_dir = Dir.mktmpdir("discourse-pdf-#{SecureRandom.hex(8)}") - output_path = File.join(temp_dir, "page-#{page_number}.png") - - # Extract specific page using ImageMagick - # image magick uses 0 based page numbers - command = [ - "magick", - "-density", - "300", - "#{pdf_path}[#{page_number - 1}]", - "-background", - "white", - "-auto-orient", - "-quality", - "85", - output_path, - ] - - Discourse::Utils.execute_command( - *command, - failure_message: "Failed to convert PDF page #{page_number} to image", - timeout: 30, - ) - - # TODO - we are creating leftover uploads, they will be cleaned up - # but maybe we should just keep them around? - upload = - UploadCreator.new(File.open(output_path), "page-#{page_number}.png").create_for(@user&.id) - - DiscourseAi::Utils::ImageToText - .new(upload: upload, llm_model: @llm_model, user: @user, guidance_text: text) - .extract_text { |chunk| yield chunk } - ensure - FileUtils.rm_rf(temp_dir) if temp_dir - end - end -end diff --git a/lib/utils/research/filter.rb b/lib/utils/research/filter.rb deleted file mode 100644 index eede8600..00000000 --- a/lib/utils/research/filter.rb +++ /dev/null @@ -1,395 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Utils - module Research - class Filter - def self.register_filter(matcher, &block) - (@registered_filters ||= {})[matcher] = block - end - - def self.registered_filters - @registered_filters ||= {} - end - - def self.word_to_date(str) - ::Search.word_to_date(str) - end - - attr_reader :term, :filters, :order, :guardian, :limit, :offset, :invalid_filters - - register_filter(/\Astatus:open\z/i) do |relation, _, _| - relation.where("topics.closed = false AND topics.archived = false") - end - - register_filter(/\Astatus:closed\z/i) do |relation, _, _| - relation.where("topics.closed = true") - end - - register_filter(/\Astatus:archived\z/i) do |relation, _, _| - relation.where("topics.archived = true") - end - - register_filter(/\Astatus:noreplies\z/i) do |relation, _, _| - relation.where("topics.posts_count = 1") - end - - register_filter(/\Astatus:single_user\z/i) do |relation, _, _| - relation.where("topics.participant_count = 1") - end - - # Date filters - register_filter(/\Abefore:(.*)\z/i) do |relation, date_str, _| - if date = Filter.word_to_date(date_str) - relation.where("posts.created_at < ?", date) - else - relation - end - end - - register_filter(/\Aafter:(.*)\z/i) do |relation, date_str, _| - if date = Filter.word_to_date(date_str) - relation.where("posts.created_at > ?", date) - else - relation - end - end - - register_filter(/\Atopic_before:(.*)\z/i) do |relation, date_str, _| - if date = Filter.word_to_date(date_str) - relation.where("topics.created_at < ?", date) - else - relation - end - end - - register_filter(/\Atopic_after:(.*)\z/i) do |relation, date_str, _| - if date = Filter.word_to_date(date_str) - relation.where("topics.created_at > ?", date) - else - relation - end - end - - register_filter(/\A(?:tags?|tag):(.*)\z/i) do |relation, tag_param, _| - if tag_param.include?(",") - tag_names = tag_param.split(",").map(&:strip) - tag_ids = Tag.where(name: tag_names).pluck(:id) - return relation.where("1 = 0") if tag_ids.empty? - relation.where(topic_id: TopicTag.where(tag_id: tag_ids).select(:topic_id)) - else - if tag = Tag.find_by(name: tag_param) - relation.where(topic_id: TopicTag.where(tag_id: tag.id).select(:topic_id)) - else - relation.where("1 = 0") - end - end - end - - register_filter(/\Akeywords?:(.*)\z/i) do |relation, keywords_param, _| - if keywords_param.blank? - relation - else - keywords = keywords_param.split(",").map(&:strip).reject(&:blank?) - if keywords.empty? - relation - else - # Build a ts_query string joined by | (OR) - ts_query = keywords.map { |kw| kw.gsub(/['\\]/, " ") }.join(" | ") - relation = - relation.joins("JOIN post_search_data ON post_search_data.post_id = posts.id") - relation.where( - "post_search_data.search_data @@ to_tsquery(?, ?)", - ::Search.ts_config, - ts_query, - ) - end - end - end - - register_filter(/\Atopic_keywords?:(.*)\z/i) do |relation, keywords_param, _| - if keywords_param.blank? - relation - else - keywords = keywords_param.split(",").map(&:strip).reject(&:blank?) - if keywords.empty? - relation - else - ts_query = keywords.map { |kw| kw.gsub(/['\\]/, " ") }.join(" | ") - - relation.where( - "posts.topic_id IN ( - SELECT posts2.topic_id - FROM posts posts2 - JOIN post_search_data ON post_search_data.post_id = posts2.id - WHERE post_search_data.search_data @@ to_tsquery(?, ?) - )", - ::Search.ts_config, - ts_query, - ) - end - end - end - - register_filter(/\A(?:categories?|category):(.*)\z/i) do |relation, category_param, _| - if category_param.include?(",") - category_names = category_param.split(",").map(&:strip) - - found_category_ids = [] - category_names.each do |name| - category = Category.find_by(slug: name) || Category.find_by(name: name) - found_category_ids << category.id if category - end - - return relation.where("1 = 0") if found_category_ids.empty? - relation.where(topic_id: Topic.where(category_id: found_category_ids).select(:id)) - else - if category = - Category.find_by(slug: category_param) || Category.find_by(name: category_param) - relation.where(topic_id: Topic.where(category_id: category.id).select(:id)) - else - relation.where("1 = 0") - end - end - end - - register_filter(/\Ausernames?:(.+)\z/i) do |relation, username, filter| - user_ids = User.where(username_lower: username.split(",").map(&:downcase)).pluck(:id) - if user_ids.empty? - relation.where("1 = 0") - else - relation.where("posts.user_id IN (?)", user_ids) - end - end - - def self.assign_allowed?(guardian) - SiteSetting.respond_to?(:assign_enabled) && SiteSetting.assign_enabled && - (guardian.can_assign? || SiteSetting.assigns_public) - end - - register_filter(/\Aassigned_to:(.+)\z/i) do |relation, name, filter| - if !assign_allowed?(filter.guardian) - raise Discourse::InvalidAccess.new( - "Assigns are not enabled or you do not have permission to see assigns.", - ) - end - - if (name == "nobody") - relation.joins("LEFT JOIN assignments a ON a.topic_id = topics.id AND a.active").where( - "a.assigned_to_id IS NULL", - ) - elsif name == "*" - relation.joins("JOIN assignments a ON a.topic_id = topics.id AND a.active").where( - "a.assigned_to_id IS NOT NULL", - ) - else - usernames = name.split(",").map(&:strip).map(&:downcase) - relation.joins("JOIN assignments a ON a.topic_id = topics.id AND a.active").where( - "a.assigned_to_id" => User.where(username_lower: usernames).select(:id), - ) - end - end - - register_filter(/\Agroups?:([a-zA-Z0-9_\-,]+)\z/i) do |relation, groups_param, filter| - if groups_param.include?(",") - group_names = groups_param.split(",").map(&:strip) - found_group_ids = [] - group_names.each do |name| - group = Group.find_by("name ILIKE ?", name) - found_group_ids << group.id if group - end - - return relation.where("1 = 0") if found_group_ids.empty? - relation.where( - "posts.user_id IN ( - SELECT gu.user_id FROM group_users gu - WHERE gu.group_id IN (?) - )", - found_group_ids, - ) - else - group = Group.find_by("name ILIKE ?", groups_param) - if group - relation.where( - "posts.user_id IN ( - SELECT gu.user_id FROM group_users gu - WHERE gu.group_id = ? - )", - group.id, - ) - else - relation.where("1 = 0") # No results if group doesn't exist - end - end - end - - register_filter(/\Amax_results:(\d+)\z/i) do |relation, limit_str, filter| - filter.limit_by_user!(limit_str.to_i) - relation - end - - register_filter(/\Aorder:latest\z/i) do |relation, order_str, filter| - filter.set_order!(:latest_post) - relation - end - - register_filter(/\Aorder:oldest\z/i) do |relation, order_str, filter| - filter.set_order!(:oldest_post) - relation - end - - register_filter(/\Aorder:latest_topic\z/i) do |relation, order_str, filter| - filter.set_order!(:latest_topic) - relation - end - - register_filter(/\Aorder:oldest_topic\z/i) do |relation, order_str, filter| - filter.set_order!(:oldest_topic) - relation - end - - register_filter(/\Aorder:likes\z/i) do |relation, order_str, filter| - filter.set_order!(:likes) - relation - end - - register_filter(/\Atopics?:(.*)\z/i) do |relation, topic_param, filter| - if topic_param.include?(",") - topic_ids = topic_param.split(",").map(&:strip).map(&:to_i).reject(&:zero?) - return relation.where("1 = 0") if topic_ids.empty? - relation.where("posts.topic_id IN (?)", topic_ids) - else - topic_id = topic_param.to_i - if topic_id > 0 - relation.where("posts.topic_id = ?", topic_id) - else - relation.where("1 = 0") # No results if topic_id is invalid - end - end - end - - register_filter(/\Apost_type:(first|reply)\z/i) do |relation, post_type, _| - if post_type.downcase == "first" - relation.where("posts.post_number = 1") - elsif post_type.downcase == "reply" - relation.where("posts.post_number > 1") - else - relation - end - end - - def initialize(term, guardian: nil, limit: nil, offset: nil) - @guardian = guardian || Guardian.new - @limit = limit - @offset = offset - @filters = [] - @valid = true - @order = :latest_post - @invalid_filters = [] - @term = term.to_s.strip - @or_groups = [] - - process_filters(@term) - end - - def set_order!(order) - @order = order - end - - def limit_by_user!(limit) - @limit = limit if limit.to_i < @limit.to_i || @limit.nil? - end - - def search - base_relation = - Post - .secured(@guardian) - .joins(:topic) - .merge(Topic.secured(@guardian)) - .where("topics.archetype = 'regular'") - - # Handle OR groups - if @or_groups.any? - or_relations = - @or_groups.map do |or_group| - group_relation = base_relation - or_group.each do |filter_block, match_data| - group_relation = filter_block.call(group_relation, match_data, self) - end - group_relation - end - - # Combine OR groups - filtered = or_relations.reduce { |combined, current| combined.or(current) } - else - filtered = base_relation - end - - # Apply regular AND filters - @filters.each do |filter_block, match_data| - filtered = filter_block.call(filtered, match_data, self) - end - - filtered = filtered.limit(@limit) if @limit.to_i > 0 - filtered = filtered.offset(@offset) if @offset.to_i > 0 - - if @order == :latest_post - filtered = filtered.order("posts.created_at DESC") - elsif @order == :oldest_post - filtered = filtered.order("posts.created_at ASC") - elsif @order == :latest_topic - filtered = filtered.order("topics.created_at DESC, posts.post_number DESC") - elsif @order == :oldest_topic - filtered = filtered.order("topics.created_at ASC, posts.post_number ASC") - elsif @order == :likes - filtered = filtered.order("posts.like_count DESC, posts.created_at DESC") - end - - filtered - end - - def process_filters(term) - return if term.blank? - - # Split by OR first, then process each group - or_parts = term.split(/\s+OR\s+/i) - - if or_parts.size > 1 - # Multiple OR groups - or_parts.each do |or_part| - group_filters = [] - process_filter_group(or_part.strip, group_filters) - @or_groups << group_filters if group_filters.any? - end - else - # Single group (AND logic) - process_filter_group(term, @filters) - end - end - - private - - def process_filter_group(term_part, filter_collection) - term_part - .to_s - .scan(/(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/) - .to_a - .map do |(word, _)| - next if word.blank? - - found = false - self.class.registered_filters.each do |matcher, block| - if word =~ matcher - filter_collection << [block, $1] - found = true - break - end - end - - invalid_filters << word if !found - end - end - end - end - end -end diff --git a/lib/utils/research/llm_formatter.rb b/lib/utils/research/llm_formatter.rb deleted file mode 100644 index a762f2dd..00000000 --- a/lib/utils/research/llm_formatter.rb +++ /dev/null @@ -1,205 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Utils - module Research - class LlmFormatter - def initialize(filter, max_tokens_per_batch:, tokenizer:, max_tokens_per_post:) - @filter = filter - @max_tokens_per_batch = max_tokens_per_batch - @tokenizer = tokenizer - @max_tokens_per_post = max_tokens_per_post - @to_process = filter_to_hash - end - - def each_chunk - return nil if @to_process.empty? - - result = { post_count: 0, topic_count: 0, text: +"" } - estimated_tokens = 0 - - @to_process.each do |topic_id, topic_data| - topic = Topic.find_by(id: topic_id) - next unless topic - - topic_text, topic_tokens, post_count = format_topic(topic, topic_data[:posts]) - - # If this single topic exceeds our token limit and we haven't added anything yet, - # we need to include at least this one topic (partial content) - if estimated_tokens == 0 && topic_tokens > @max_tokens_per_batch - offset = 0 - while offset < topic_text.length - chunk = +"" - chunk_tokens = 0 - lines = topic_text[offset..].lines - lines.each do |line| - line_tokens = estimate_tokens(line) - break if chunk_tokens + line_tokens > @max_tokens_per_batch - chunk << line - chunk_tokens += line_tokens - end - break if chunk.empty? - yield( - { - text: chunk, - post_count: post_count, # This may overcount if split mid-topic, but preserves original logic - topic_count: 1, - } - ) - offset += chunk.length - end - - next - end - - # If adding this topic would exceed our token limit and we already have content, skip it - if estimated_tokens > 0 && estimated_tokens + topic_tokens > @max_tokens_per_batch - yield result if result[:text].present? - estimated_tokens = 0 - result = { post_count: 0, topic_count: 0, text: +"" } - else - # Add this topic to the result - result[:text] << topic_text - result[:post_count] += post_count - result[:topic_count] += 1 - estimated_tokens += topic_tokens - end - end - yield result if result[:text].present? - - @to_process.clear - end - - private - - def filter_to_hash - hash = {} - @filter - .search - .pluck(:topic_id, :id, :post_number) - .each do |topic_id, post_id, post_number| - hash[topic_id] ||= { posts: [] } - hash[topic_id][:posts] << [post_id, post_number] - end - - hash.each_value { |topic| topic[:posts].sort_by! { |_, post_number| post_number } } - hash - end - - def format_topic(topic, posts_data) - text = "" - total_tokens = 0 - post_count = 0 - - # Add topic header - text += format_topic_header(topic) - total_tokens += estimate_tokens(text) - - # Get all post numbers in this topic - all_post_numbers = topic.posts.pluck(:post_number).sort - - # Format posts with omitted information - first_post_number = posts_data.first[1] - last_post_number = posts_data.last[1] - - # Handle posts before our selection - if first_post_number > 1 - omitted_before = first_post_number - 1 - text += format_omitted_posts(omitted_before, "before") - total_tokens += estimate_tokens(format_omitted_posts(omitted_before, "before")) - end - - # Format each post - posts_data.each do |post_id, post_number| - post = Post.find_by(id: post_id) - next unless post - - text += format_post(post) - total_tokens += estimate_tokens(format_post(post)) - post_count += 1 - end - - # Handle posts after our selection - if last_post_number < all_post_numbers.last - omitted_after = all_post_numbers.last - last_post_number - text += format_omitted_posts(omitted_after, "after") - total_tokens += estimate_tokens(format_omitted_posts(omitted_after, "after")) - end - - [text, total_tokens, post_count] - end - - def format_topic_header(topic) - header = +"# #{topic.title}\n" - - # Add category - header << "Category: #{topic.category.name}\n" if topic.category - - # Add tags - header << "Tags: #{topic.tags.map(&:name).join(", ")}\n" if topic.tags.present? - - # Add creation date - header << "Created: #{format_date(topic.created_at)}\n" - header << "Topic url: /t/#{topic.id}\n" - header << "Status: #{format_topic_status(topic)}\n\n" - - header - end - - def format_topic_status(topic) - solved = topic.respond_to?(:solved) && topic.solved.present? - solved_text = solved ? " (solved)" : "" - if topic.archived? - "Archived#{solved_text}" - elsif topic.closed? - "Closed#{solved_text}" - else - "Open#{solved_text}" - end - end - - def format_post(post) - text = +"---\n" - text << "## Post by #{post.user&.username} - #{format_date(post.created_at)}\n\n" - text << "#{truncate_if_needed(post.raw)}\n" - text << "Likes: #{post.like_count}\n" if post.like_count.to_i > 0 - text << "Post url: /t/-/#{post.topic_id}/#{post.post_number}\n\n" - text - end - - def truncate_if_needed(content) - tokens_count = estimate_tokens(content) - - return content if tokens_count <= @max_tokens_per_post - - half_limit = @max_tokens_per_post / 2 - token_ids = @tokenizer.encode(content) - - first_half_ids = token_ids[0...half_limit] - last_half_ids = token_ids[-half_limit..-1] - - first_text = @tokenizer.decode(first_half_ids) - last_text = @tokenizer.decode(last_half_ids) - - "#{first_text}\n\n... elided #{tokens_count - @max_tokens_per_post} tokens ...\n\n#{last_text}" - end - - def format_omitted_posts(count, position) - if position == "before" - "#{count} earlier #{count == 1 ? "post" : "posts"} omitted\n\n" - else - "#{count} later #{count == 1 ? "post" : "posts"} omitted\n\n" - end - end - - def format_date(date) - date.strftime("%Y-%m-%d %H:%M") - end - - def estimate_tokens(text) - @tokenizer.tokenize(text).length - end - end - end - end -end diff --git a/lib/utils/search.rb b/lib/utils/search.rb deleted file mode 100644 index 6c5c9718..00000000 --- a/lib/utils/search.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Utils - class Search - def self.perform_search( - search_query: nil, - category: nil, - user: nil, - order: nil, - max_posts: nil, - tags: nil, - before: nil, - after: nil, - status: nil, - hyde: true, - max_results: 20, - current_user: nil, - result_style: :compact - ) - search_terms = [] - - search_terms << search_query.strip if search_query.present? - search_terms << "category:#{category}" if category.present? - search_terms << "user:#{user}" if user.present? - search_terms << "order:#{order}" if order.present? - search_terms << "max_posts:#{max_posts}" if max_posts.present? - search_terms << "tags:#{tags}" if tags.present? - search_terms << "before:#{before}" if before.present? - search_terms << "after:#{after}" if after.present? - search_terms << "status:#{status}" if status.present? - - guardian = Guardian.new(current_user) - - search_string = search_terms.join(" ").to_s - - begin - results = ::Search.execute(search_string, search_type: :full_page, guardian: guardian) - rescue Discourse::InvalidAccess => e - return( - { - args: { - search_query: search_query, - category: category, - user: user, - order: order, - max_posts: max_posts, - tags: tags, - before: before, - after: after, - status: status, - max_results: max_results, - }.compact, - rows: [], - instruction: I18n.t("invalid_access"), - error: e.message, - } - ) - end - - results_limit = max_results - - should_try_semantic_search = - SiteSetting.ai_embeddings_semantic_search_enabled && search_query.present? - - max_semantic_results = max_results / 4 - results_limit = results_limit - max_semantic_results if should_try_semantic_search - - posts = results&.posts || [] - posts = posts[0..results_limit.to_i - 1] - - if should_try_semantic_search - semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian) - topic_ids = Set.new(posts.map(&:topic_id)) - - search = ::Search.new(search_string, guardian: guardian) - - semantic_results = nil - begin - semantic_results = semantic_search.search_for_topics(search.term, hyde: hyde) - rescue => e - Discourse.warn_exception(e, message: "Semantic search failed") - end - - if semantic_results - semantic_results = search.apply_filters(semantic_results) - - semantic_results.each do |post| - next if topic_ids.include?(post.topic_id) - - topic_ids << post.topic_id - posts << post - - break if posts.length >= max_results - end - end - end - - hidden_tags = nil - - # Construct search_args hash for consistent return format - search_args = { - search_query: search_query, - category: category, - user: user, - order: order, - max_posts: max_posts, - tags: tags, - before: before, - after: after, - status: status, - max_results: max_results, - }.compact - - if posts.blank? - { args: search_args, rows: [], instruction: "nothing was found, expand your search" } - else - format_results(posts, args: search_args, result_style: result_style) do |post| - category_names = [ - post.topic.category&.parent_category&.name, - post.topic.category&.name, - ].compact.join(" > ") - row = { - title: post.topic.title, - url: Discourse.base_path + post.url, - username: post.user&.username, - excerpt: post.excerpt, - created: post.created_at, - category: category_names, - likes: post.like_count, - topic_views: post.topic.views, - topic_likes: post.topic.like_count, - topic_replies: post.topic.posts_count - 1, - } - - if SiteSetting.tagging_enabled - hidden_tags ||= DiscourseTagging.hidden_tag_names - tags = post.topic.tags.map(&:name) - hidden_tags - row[:tags] = tags.join(", ") if tags.present? - end - - row - end - end - end - - private - - def self.format_results(rows, args: nil, result_style:) - rows = rows&.map { |row| yield row } if block_given? - column_names = nil - - if result_style == :compact - index = -1 - column_indexes = {} - - rows = - rows&.map do |data| - new_row = [] - data.each do |key, value| - found_index = column_indexes[key.to_s] ||= (index += 1) - new_row[found_index] = value - end - new_row - end - column_names = column_indexes.keys - end - - result = { rows: rows } - result[:column_names] = column_names if column_names - result[:args] = args if args - result - end - end - end -end diff --git a/package.json b/package.json deleted file mode 100644 index 8cdc01bf..00000000 --- a/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "private": true, - "devDependencies": { - "@discourse/lint-configs": "2.11.1", - "ember-template-lint": "7.0.1", - "eslint": "9.22.0", - "prettier": "3.5.3", - "stylelint": "16.16.0" - }, - "engines": { - "node": ">= 22", - "npm": "please-use-pnpm", - "yarn": "please-use-pnpm", - "pnpm": "9.x" - }, - "packageManager": "pnpm@9.15.5" -} diff --git a/plugin.rb b/plugin.rb deleted file mode 100644 index 6758238f..00000000 --- a/plugin.rb +++ /dev/null @@ -1,160 +0,0 @@ -# frozen_string_literal: true - -# name: discourse-ai -# about: Enables integration between AI modules and features in Discourse -# meta_topic_id: 259214 -# version: 0.0.1 -# authors: Discourse -# url: https://meta.discourse.org/t/discourse-ai/259214 -# required_version: 2.7.0 - -gem "tokenizers", "0.5.4" -gem "tiktoken_ruby", "0.0.11.1" -gem "discourse_ai-tokenizers", "0.3.1", require_name: "discourse_ai/tokenizers" -gem "ed25519", "1.2.4" #TODO remove this as existing ssl gem should handle this - -# we probably want to move all dependencies directly in to the Discourse Gemfile, this -# will give us a strong guarantee that the dependencies are compatible and keep getting upgraded -gem "Ascii85", "2.0.1", require: false -gem "ruby-rc4", "0.1.5", require: false -gem "hashery", "2.1.2", require: false -gem "ttfunk", "1.8.0", require: false -gem "afm", "0.2.2", require: false -# all above are required by pdf-reader -gem "pdf-reader", "2.14.1", require: false - -enabled_site_setting :discourse_ai_enabled - -register_asset "stylesheets/common/streaming.scss" -register_asset "stylesheets/common/ai-blinking-animation.scss" -register_asset "stylesheets/common/ai-user-settings.scss" -register_asset "stylesheets/common/ai-features.scss" - -register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss" -register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop -register_asset "stylesheets/modules/ai-helper/mobile/ai-helper.scss", :mobile - -register_asset "stylesheets/modules/summarization/common/ai-summary.scss" -register_asset "stylesheets/modules/summarization/desktop/ai-summary.scss", :desktop - -register_asset "stylesheets/modules/summarization/common/ai-gists.scss" - -register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss" -register_asset "stylesheets/modules/ai-bot/common/ai-persona.scss" -register_asset "stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss" -register_asset "stylesheets/modules/ai-bot/mobile/ai-persona.scss", :mobile - -register_asset "stylesheets/modules/ai-bot-conversations/common.scss" - -register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss" -register_asset "stylesheets/modules/embeddings/common/semantic-search.scss" - -register_asset "stylesheets/modules/sentiment/common/dashboard.scss" - -register_asset "stylesheets/modules/llms/common/ai-llms-editor.scss" -register_asset "stylesheets/modules/embeddings/common/ai-embedding-editor.scss" - -register_asset "stylesheets/modules/llms/common/usage.scss" -register_asset "stylesheets/modules/llms/common/spam.scss" -register_asset "stylesheets/modules/llms/common/ai-llm-quotas.scss" - -register_asset "stylesheets/modules/ai-bot/common/ai-tools.scss" - -register_asset "stylesheets/modules/ai-bot/common/ai-artifact.scss" - -module ::DiscourseAi - PLUGIN_NAME = "discourse-ai" - - def self.public_asset_path(name) - File.expand_path(File.join(__dir__, "public", name)) - end -end - -Rails.autoloaders.main.push_dir(File.join(__dir__, "lib"), namespace: ::DiscourseAi) - -require_relative "lib/engine" -require_relative "lib/configuration/module" - -::DiscourseAi::Configuration::Module::NAMES.each do |module_name| - register_site_setting_area("ai-features/#{module_name}") -end - -after_initialize do - if defined?(Rack::MiniProfiler) - Rack::MiniProfiler.config.skip_paths << "/discourse-ai/ai-bot/artifacts" - end - - # do not autoload this cause we may have no namespace - require_relative "discourse_automation/llm_triage" - require_relative "discourse_automation/llm_report" - require_relative "discourse_automation/llm_tool_triage" - require_relative "discourse_automation/llm_persona_triage" - - add_admin_route("discourse_ai.title", "discourse-ai", { use_new_show_route: true }) - - register_seedfu_fixtures(Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "personas")) - - [ - DiscourseAi::Embeddings::EntryPoint.new, - DiscourseAi::Sentiment::EntryPoint.new, - DiscourseAi::AiHelper::EntryPoint.new, - DiscourseAi::Summarization::EntryPoint.new, - DiscourseAi::AiBot::EntryPoint.new, - DiscourseAi::AiModeration::EntryPoint.new, - DiscourseAi::Translation::EntryPoint.new, - ].each { |a_module| a_module.inject_into(self) } - - register_problem_check ProblemCheck::AiLlmStatus - - register_reviewable_type ReviewableAiChatMessage - register_reviewable_type ReviewableAiPost - - on(:reviewable_transitioned_to) do |new_status, reviewable| - ModelAccuracy.adjust_model_accuracy(new_status, reviewable) - if DiscourseAi::AiModeration::SpamScanner.enabled? - DiscourseAi::AiModeration::SpamMetric.update(new_status, reviewable) - end - end - - if Rails.env.test? - require_relative "spec/support/embeddings_generation_stubs" - require_relative "spec/support/stable_diffusion_stubs" - end - - reloadable_patch do |plugin| - Guardian.prepend DiscourseAi::GuardianExtensions - Topic.prepend DiscourseAi::TopicExtensions - Post.prepend DiscourseAi::PostExtensions - end - - register_modifier(:post_should_secure_uploads?) do |_, _, topic| - if topic.private_message? && SharedAiConversation.exists?(target: topic) - false - else - # revert to default behavior - # even though this can be shortened this is the clearest way to express it - nil - end - end - - add_api_key_scope( - :discourse_ai, - { update_personas: { actions: %w[discourse_ai/admin/ai_personas#update] } }, - ) - - plugin_icons = %w[ - chart-column - spell-check - language - images - far-copy - robot - info - bars-staggered - far-circle-question - face-smile - face-meh - face-angry - ] - plugin_icons.each { |icon| register_svg_icon(icon) } -end diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index ae987b46..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,4021 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: false - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@discourse/lint-configs': - specifier: 2.11.1 - version: 2.11.1(ember-template-lint@7.0.1(@babel/core@7.26.10))(eslint@9.22.0)(postcss@8.5.3)(prettier@3.5.3)(stylelint@16.16.0(typescript@5.8.2)) - ember-template-lint: - specifier: 7.0.1 - version: 7.0.1(@babel/core@7.26.10) - eslint: - specifier: 9.22.0 - version: 9.22.0 - prettier: - specifier: 3.5.3 - version: 3.5.3 - stylelint: - specifier: 16.16.0 - version: 16.16.0(typescript@5.8.2) - -packages: - - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@babel/code-frame@7.26.2': - resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.26.8': - resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.26.10': - resolution: {integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==} - engines: {node: '>=6.9.0'} - - '@babel/eslint-parser@7.26.10': - resolution: {integrity: sha512-QsfQZr4AiLpKqn7fz+j7SN+f43z2DZCgGyYbNJ2vJOqKfG4E6MZer1+jqGZqKJaxq/gdO2DC/nUu45+pOL5p2Q==} - engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0} - peerDependencies: - '@babel/core': ^7.11.0 - eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 - - '@babel/generator@7.26.10': - resolution: {integrity: sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==} - engines: {node: '>=6.9.0'} - - '@babel/helper-annotate-as-pure@7.25.9': - resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.26.5': - resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-create-class-features-plugin@7.26.9': - resolution: {integrity: sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-member-expression-to-functions@7.25.9': - resolution: {integrity: sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.25.9': - resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.26.0': - resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-optimise-call-expression@7.25.9': - resolution: {integrity: sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.26.5': - resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-replace-supers@7.26.5': - resolution: {integrity: sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.25.9': - resolution: {integrity: sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.25.9': - resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.25.9': - resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.25.9': - resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.26.10': - resolution: {integrity: sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.26.10': - resolution: {integrity: sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-proposal-decorators@7.25.9': - resolution: {integrity: sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-decorators@7.25.9': - resolution: {integrity: sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.25.9': - resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typescript@7.26.8': - resolution: {integrity: sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.26.9': - resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.26.10': - resolution: {integrity: sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.26.10': - resolution: {integrity: sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==} - engines: {node: '>=6.9.0'} - - '@csstools/css-parser-algorithms@3.0.4': - resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-tokenizer': ^3.0.3 - - '@csstools/css-tokenizer@3.0.3': - resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} - engines: {node: '>=18'} - - '@csstools/media-query-list-parser@4.0.2': - resolution: {integrity: sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.4 - '@csstools/css-tokenizer': ^3.0.3 - - '@csstools/selector-specificity@5.0.0': - resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} - engines: {node: '>=18'} - peerDependencies: - postcss-selector-parser: ^7.0.0 - - '@discourse/lint-configs@2.11.1': - resolution: {integrity: sha512-VQh052cXp43ytw+dn+pIyM5BMtgACXZ0z+tNpXVcRwiJWn8dbvuJaypFgWR6QCks3AKujypmLK8+FMnlZYZGRQ==} - peerDependencies: - ember-template-lint: 7.0.1 - eslint: 9.22.0 - prettier: 3.5.3 - stylelint: 16.16.0 - - '@dual-bundle/import-meta-resolve@4.1.0': - resolution: {integrity: sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==} - - '@ember-data/rfc395-data@0.0.4': - resolution: {integrity: sha512-tGRdvgC9/QMQSuSuJV45xoyhI0Pzjm7A9o/MVVA3HakXIImJbbzx/k/6dO9CUEQXIyS2y0fW6C1XaYOG7rY0FQ==} - - '@eslint-community/eslint-utils@4.5.1': - resolution: {integrity: sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.19.2': - resolution: {integrity: sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.1.0': - resolution: {integrity: sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.12.0': - resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.0': - resolution: {integrity: sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.22.0': - resolution: {integrity: sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.2.7': - resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@glimmer/env@0.1.7': - resolution: {integrity: sha512-JKF/a9I9jw6fGoz8kA7LEQslrwJ5jms5CXhu/aqkBWk+PmZ6pTl8mlb/eJ/5ujBGTiQzBhy5AIWF712iA+4/mw==} - - '@glimmer/global-context@0.84.3': - resolution: {integrity: sha512-8Oy9Wg5IZxMEeAnVmzD2NkObf89BeHoFSzJgJROE/deutd3rxg83mvlOez4zBBGYwnTb+VGU2LYRpet92egJjA==} - - '@glimmer/interfaces@0.84.3': - resolution: {integrity: sha512-dk32ykoNojt0mvEaIW6Vli5MGTbQo58uy3Epj7ahCgTHmWOKuw/0G83f2UmFprRwFx689YTXG38I/vbpltEjzg==} - - '@glimmer/interfaces@0.92.3': - resolution: {integrity: sha512-QwQeA01N+0h+TAi/J7iUnZtRuJy+093hNyagxDQBA6b1wCBw+q+al9+O6gmbWlkWE7EifzmNE1nnrgcecJBlJQ==} - - '@glimmer/reference@0.84.3': - resolution: {integrity: sha512-lV+p/aWPVC8vUjmlvYVU7WQJsLh319SdXuAWoX/SE3pq340BJlAJiEcAc6q52y9JNhT57gMwtjMX96W5Xcx/qw==} - - '@glimmer/syntax@0.84.3': - resolution: {integrity: sha512-ioVbTic6ZisLxqTgRBL2PCjYZTFIwobifCustrozRU2xGDiYvVIL0vt25h2c1ioDsX59UgVlDkIK4YTAQQSd2A==} - - '@glimmer/syntax@0.92.3': - resolution: {integrity: sha512-7wPKQmULyXCYf0KvbPmfrs/skPISH2QGR9atCnmDWnHyLv5SSZVLm1P0Ctrpta6+Ci3uGQb7hGk0IjsLEavcYQ==} - - '@glimmer/util@0.84.3': - resolution: {integrity: sha512-qFkh6s16ZSRuu2rfz3T4Wp0fylFj3HBsONGXQcrAdZjdUaIS6v3pNj6mecJ71qRgcym9Hbaq/7/fefIwECUiKw==} - - '@glimmer/util@0.92.3': - resolution: {integrity: sha512-K1oH93gGU36slycxJ9CcFpUTsdOc4XQ6RuZFu5oRsxFYtEF5PSu7ik11h58fyeoaWOr1ebfkyAMawbeI2AJ5GA==} - - '@glimmer/validator@0.84.3': - resolution: {integrity: sha512-RTBV4TokUB0vI31UC7ikpV7lOYpWUlyqaKV//pRC4pexYMlmqnVhkFrdiimB/R1XyNdUOQUmnIAcdic39NkbhQ==} - - '@glimmer/wire-format@0.92.3': - resolution: {integrity: sha512-gFz81Q9+V7Xs0X8mSq6y8qacHm0dPaGJo2/Bfcsdow1hLOKNgTCLr4XeDBhRML8f6I6Gk9ugH4QDxyIOXOpC4w==} - - '@handlebars/parser@2.0.0': - resolution: {integrity: sha512-EP9uEDZv/L5Qh9IWuMUGJRfwhXJ4h1dqKTT4/3+tY0eu7sPis7xh23j61SYUnNF4vqCQvvUXpDo9Bh/+q1zASA==} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.6': - resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.3.1': - resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} - engines: {node: '>=18.18'} - - '@humanwhocodes/retry@0.4.2': - resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} - engines: {node: '>=18.18'} - - '@jridgewell/gen-mapping@0.3.8': - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - - '@keyv/serialize@1.0.3': - resolution: {integrity: sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==} - - '@lint-todo/utils@13.1.1': - resolution: {integrity: sha512-F5z53uvRIF4dYfFfJP3a2Cqg+4P1dgJchJsFnsZE0eZp0LK8X7g2J0CsJHRgns+skpXOlM7n5vFGwkWCWj8qJg==} - engines: {node: 12.* || >= 14} - - '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': - resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - - '@simple-dom/interface@1.4.0': - resolution: {integrity: sha512-l5qumKFWU0S+4ZzMaLXFU8tQZsicHEMEyAxI5kDFGhJsRqDwe0a7/iPA/GdxlGyDKseQQAgIz5kzU7eXTrlSpA==} - - '@sindresorhus/merge-streams@2.3.0': - resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} - engines: {node: '>=18'} - - '@types/eslint@8.56.12': - resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==} - - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.8: - resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} - engines: {node: '>= 0.4'} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - array.prototype.findlastindex@1.2.5: - resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - - astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - async-promise-queue@1.0.5: - resolution: {integrity: sha512-xi0aQ1rrjPWYmqbwr18rrSKbSaXIeIwSd1J4KAgVfkq8utNbdZoht7GfvfY6swFUAMJ9obkc4WPJmtGwl+B8dw==} - - async@2.6.4: - resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} - - at-least-node@1.0.0: - resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} - engines: {node: '>= 4.0.0'} - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@2.0.0: - resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.24.4: - resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - - cacheable@1.8.9: - resolution: {integrity: sha512-FicwAUyWnrtnd4QqYAoRlNs44/a1jTL7XDKqm5gJ90wz1DQPlC7U2Rd1Tydpv+E7WAr4sQHuw8Q8M3nZMAyecQ==} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - caniuse-lite@1.0.30001704: - resolution: {integrity: sha512-+L2IgBbV6gXB4ETf0keSvLr7JUrRVbIaB/lrQ1+z8mRcQiisG5k+lG6O4n6Y5q6f5EuNfaYXKgymucphlEXQew==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.4.1: - resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - ci-info@4.2.0: - resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} - engines: {node: '>=8'} - - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - colord@2.9.3: - resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} - - colors@1.4.0: - resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} - engines: {node: '>=0.1.90'} - - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - content-tag-utils@0.3.1: - resolution: {integrity: sha512-uBAmklAxqmem7WX/F7YA4eWBTegGLJ93MOPSJaJliAuzTNgF3u/H9YLlJ6Nae9/FwV+037OuXc75hvcmub1qww==} - engines: {node: '>= 18'} - - content-tag@2.0.3: - resolution: {integrity: sha512-htLIdtfhhKW2fHlFLnZH7GFzHSdSpHhDLrWVswkNiiPMZ5uXq5JfrGboQKFhNQuAAFF8VNB2EYUj3MsdJrKKpg==} - - content-tag@3.1.1: - resolution: {integrity: sha512-94puwVk6X8oJcbRIEY03UM80zWzA3dYgGkOiRJzeY1vXgwrFUh3OolDDi/D7YBa6Vsx+CgAvuk4uXlB8loZ1FA==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - css-functions-list@3.2.3: - resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} - engines: {node: '>=12 || >=16'} - - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - date-fns@3.6.0: - resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} - - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - dot-case@3.0.4: - resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - electron-to-chromium@1.5.118: - resolution: {integrity: sha512-yNDUus0iultYyVoEFLnQeei7LOQkL8wg8GQpkPCRrOlJXlcCwa6eGKZkxQ9ciHsqZyYbj8Jd94X1CTPzGm+uIA==} - - ember-eslint-parser@0.5.9: - resolution: {integrity: sha512-IW4/3cEiFp49M2LiKyzi7VcT1egogOe8UxQ9eUKTooenC7Q4qNhzTD6rOZ8j51m8iJC+8hCzjbNCa3K4CN0Hhg==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@babel/core': ^7.23.6 - '@typescript-eslint/parser': '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - ember-rfc176-data@0.3.18: - resolution: {integrity: sha512-JtuLoYGSjay1W3MQAxt3eINWXNYYQliK90tLwtb8aeCuQK8zKGCRbBodVIrkcTqshULMnRuTOS6t1P7oQk3g6Q==} - - ember-template-lint@7.0.1: - resolution: {integrity: sha512-rFGrioqtoHyWGig6PsURQYL797Hj1u9Wbn8ZyTO3H0NUNrRgPOX+2Gu9uTrmnf+KWry/2GB+yok6OY7mVSygIA==} - engines: {node: ^18.18.0 || >= 20.9.0} - hasBin: true - - ember-template-recast@6.1.5: - resolution: {integrity: sha512-VnRN8FzEHQnw/5rCv6Wnq8MVYXbGQbFY+rEufvWV+FO/IsxMahGEud4MYWtTA2q8iG+qJFrDQefNvQ//7MI7Qw==} - engines: {node: 12.* || 14.* || >= 16.*} - hasBin: true - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - - es-abstract@1.23.9: - resolution: {integrity: sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-formatter-kakoune@1.0.0: - resolution: {integrity: sha512-Uk/TVLt6Nf6Xoz7C1iYuZjOSdJxe5aaauGRke8JhKeJwD66Y61/pY2FjtLP04Ooq9PwV34bzrkKkU2UZ5FtDRA==} - - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - - eslint-module-utils@2.12.0: - resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-decorator-position@6.0.0: - resolution: {integrity: sha512-AUbZbt3JXnmP7Typfba4BIEFkSCc2rA6BkutsYiywIcEoX/yRL7jzqAp4UMpSDNhCMUUAfGt48k3141PhKC07w==} - engines: {node: '>=14'} - peerDependencies: - '@babel/eslint-parser': ^7.18.2 - eslint: ^7.31.0 || ^8.0.0 || ^9.0.0 - peerDependenciesMeta: - '@babel/eslint-parser': - optional: true - - eslint-plugin-ember@12.5.0: - resolution: {integrity: sha512-DBUzsaKWDVXsujAZPpRir0O7owdlCoVzZmtaJm7g7iQeSrNtcRWI7AItsTqKSsws1XeAySH0sPsQItMdDCb9Fg==} - engines: {node: 18.* || 20.* || >= 21} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '>= 8' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-import@2.31.0: - resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-qunit@8.1.2: - resolution: {integrity: sha512-2gDQdHlQW8GVXD7YYkO8vbm9Ldc60JeGMuQN5QlD48OeZ8znBvvoHWZZMeXjvoDPReGaLEvyuWrDtrI8bDbcqw==} - engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} - - eslint-plugin-simple-import-sort@12.1.1: - resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} - peerDependencies: - eslint: '>=5.0.0' - - eslint-plugin-sort-class-members@1.21.0: - resolution: {integrity: sha512-QKV4jvGMu/ge1l4s1TUBC6rqqV/fbABWY7q2EeNpV3FRikoX6KuLhiNvS8UuMi+EERe0hKGrNU9e6ukFDxNnZQ==} - engines: {node: '>=4.0.0'} - peerDependencies: - eslint: '>=0.8.0' - - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-scope@8.3.0: - resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-utils@3.0.0: - resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} - engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} - peerDependencies: - eslint: '>=5' - - eslint-visitor-keys@2.1.0: - resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} - engines: {node: '>=10'} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.0: - resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@9.22.0: - resolution: {integrity: sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.3.0: - resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-uri@3.0.6: - resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} - - fastest-levenshtein@1.0.16: - resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} - engines: {node: '>= 4.9.1'} - - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - - file-entry-cache@10.0.7: - resolution: {integrity: sha512-txsf5fu3anp2ff3+gOJJzRImtrtm/oa9tYLN0iTuINZ++EyVR/nRrg2fKYwvG/pXDofcrvvb0scEbX3NyW/COw==} - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - find-up@7.0.0: - resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} - engines: {node: '>=18'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flat-cache@6.1.7: - resolution: {integrity: sha512-qwZ4xf1v1m7Rc9XiORly31YaChvKt6oNVHuqqZcoED/7O+ToyNVGobKsIAopY9ODcWpEDKEBAbrSOCBHtNQvew==} - - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - fs-extra@9.1.0: - resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} - engines: {node: '>=10'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - fuse.js@7.1.0: - resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} - engines: {node: '>=10'} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stdin@9.0.0: - resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==} - engines: {node: '>=12'} - - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - global-modules@2.0.0: - resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} - engines: {node: '>=6'} - - global-prefix@3.0.0: - resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} - engines: {node: '>=6'} - - globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@16.0.0: - resolution: {integrity: sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==} - engines: {node: '>=18'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - - globby@14.1.0: - resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} - engines: {node: '>=18'} - - globjoin@0.1.4: - resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hookified@1.8.1: - resolution: {integrity: sha512-GrO2l93P8xCWBSTBX9l2BxI78VU/MAAYag+pG8curS3aBGy0++ZlxrQ7PdUOUVMbn5BwkGb6+eRrnf43ipnFEA==} - - html-tags@3.3.1: - resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} - engines: {node: '>=8'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.3: - resolution: {integrity: sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-function@1.1.0: - resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} - engines: {node: '>= 0.4'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-plain-object@5.0.0: - resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} - engines: {node: '>=0.10.0'} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - keyv@5.3.1: - resolution: {integrity: sha512-13hQT2q2VIwOoaJdJa7nY3J8UVbYtMTJFHnwm9LI+SaQRfUiM6Em9KZeOVTCKbMnGcRIL3NSUFpAdjZCq24nLQ==} - - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} - - known-css-properties@0.35.0: - resolution: {integrity: sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==} - - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - locate-path@7.2.0: - resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - lodash.camelcase@4.3.0: - resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} - - lodash.kebabcase@4.1.1: - resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash.truncate@4.4.2: - resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - - lower-case@2.0.2: - resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mathml-tag-names@2.1.3: - resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} - - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - - mdn-data@2.18.0: - resolution: {integrity: sha512-gtCy1yim/vpHF/tq3B4Z43x3zKWpYeb4IM3d/Mf4oMYcNuoXOYEaqtoFlLHw9zd7+WNN3jNh6/WXyUrD3OIiwQ==} - - meow@13.2.0: - resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} - engines: {node: '>=18'} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.9: - resolution: {integrity: sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - no-case@3.0.4: - resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} - - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-limit@4.0.0: - resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - p-locate@6.0.0: - resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-exists@5.0.0: - resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - path-type@6.0.0: - resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} - engines: {node: '>=18'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - - postcss-media-query-parser@0.2.3: - resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} - - postcss-resolve-nested-selector@0.1.6: - resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} - - postcss-safe-parser@7.0.1: - resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} - engines: {node: '>=18.0'} - peerDependencies: - postcss: ^8.4.31 - - postcss-scss@4.0.9: - resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.4.29 - - postcss-selector-parser@7.1.0: - resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} - engines: {node: '>=4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.3: - resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier-plugin-ember-template-tag@2.0.4: - resolution: {integrity: sha512-Ude3MJyPBMr/Er5aSS9Y0dsnHWX3prpJB+Jj/BKKUT/EvG2ftnIMBsZXmRu68RJA62JJB8MdKBloYmCu2pTRNg==} - engines: {node: 18.* || >= 20} - peerDependencies: - prettier: '>= 3.0.0' - - prettier@3.5.3: - resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} - engines: {node: '>=14'} - hasBin: true - - proper-lockfile@4.1.2: - resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - requireindex@1.2.0: - resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} - engines: {node: '>=0.10.5'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} - engines: {node: '>= 0.4'} - hasBin: true - - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - simple-html-tokenizer@0.5.11: - resolution: {integrity: sha512-C2WEK/Z3HoSFbYq8tI7ni3eOo/NneSPRoPpcM7WdLjFOArFuyXEjAoCdOC3DgMfRyziZQ1hCNR4mrNdWEvD0og==} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - slash@5.1.0: - resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} - engines: {node: '>=14.16'} - - slice-ansi@4.0.0: - resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} - engines: {node: '>=10'} - - snake-case@3.0.4: - resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - stylelint-config-recommended-scss@14.1.0: - resolution: {integrity: sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==} - engines: {node: '>=18.12.0'} - peerDependencies: - postcss: ^8.3.3 - stylelint: ^16.6.1 - peerDependenciesMeta: - postcss: - optional: true - - stylelint-config-recommended@14.0.1: - resolution: {integrity: sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==} - engines: {node: '>=18.12.0'} - peerDependencies: - stylelint: ^16.1.0 - - stylelint-config-recommended@15.0.0: - resolution: {integrity: sha512-9LejMFsat7L+NXttdHdTq94byn25TD+82bzGRiV1Pgasl99pWnwipXS5DguTpp3nP1XjvLXVnEJIuYBfsRjRkA==} - engines: {node: '>=18.12.0'} - peerDependencies: - stylelint: ^16.13.0 - - stylelint-config-standard-scss@14.0.0: - resolution: {integrity: sha512-6Pa26D9mHyi4LauJ83ls3ELqCglU6VfCXchovbEqQUiEkezvKdv6VgsIoMy58i00c854wVmOw0k8W5FTpuaVqg==} - engines: {node: '>=18.12.0'} - peerDependencies: - postcss: ^8.3.3 - stylelint: ^16.11.0 - peerDependenciesMeta: - postcss: - optional: true - - stylelint-config-standard@36.0.1: - resolution: {integrity: sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==} - engines: {node: '>=18.12.0'} - peerDependencies: - stylelint: ^16.1.0 - - stylelint-config-standard@37.0.0: - resolution: {integrity: sha512-+6eBlbSTrOn/il2RlV0zYGQwRTkr+WtzuVSs1reaWGObxnxLpbcspCUYajVQHonVfxVw2U+h42azGhrBvcg8OA==} - engines: {node: '>=18.12.0'} - peerDependencies: - stylelint: ^16.13.0 - - stylelint-scss@6.11.1: - resolution: {integrity: sha512-e4rYo0UY+BIMtGeGanghrvHTjcryxgZbyFxUedp8dLFqC4P70aawNdYjRrQxbnKhu3BNr4+lt5e/53tcKXiwFA==} - engines: {node: '>=18.12.0'} - peerDependencies: - stylelint: ^16.0.2 - - stylelint@16.16.0: - resolution: {integrity: sha512-40X5UOb/0CEFnZVEHyN260HlSSUxPES+arrUphOumGWgXERHfwCD0kNBVILgQSij8iliYVwlc0V7M5bcLP9vPg==} - engines: {node: '>=18.12.0'} - hasBin: true - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-hyperlinks@3.2.0: - resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} - engines: {node: '>=14.18'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - svg-tags@1.0.0: - resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} - - table@6.9.0: - resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} - engines: {node: '>=10.0.0'} - - tmp@0.2.3: - resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} - engines: {node: '>=14.14'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - typescript@5.8.2: - resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} - engines: {node: '>=14.17'} - hasBin: true - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - unicorn-magic@0.1.0: - resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} - engines: {node: '>=18'} - - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - upath@2.0.1: - resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==} - engines: {node: '>=4'} - - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - v8-compile-cache@2.4.0: - resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==} - - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} - engines: {node: '>= 0.4'} - - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - workerpool@6.5.1: - resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - write-file-atomic@5.0.1: - resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - yocto-queue@1.2.0: - resolution: {integrity: sha512-KHBC7z61OJeaMGnF3wqNZj+GGNXOyypZviiKpQeiHirG5Ib1ImwcLBH70rbMSkKfSmUNBsdf2PwaEJtKvgmkNw==} - engines: {node: '>=12.20'} - -snapshots: - - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.25.9 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.26.8': {} - - '@babel/core@7.26.10': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.10 - '@babel/helper-compilation-targets': 7.26.5 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10) - '@babel/helpers': 7.26.10 - '@babel/parser': 7.26.10 - '@babel/template': 7.26.9 - '@babel/traverse': 7.26.10 - '@babel/types': 7.26.10 - convert-source-map: 2.0.0 - debug: 4.4.0 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/eslint-parser@7.26.10(@babel/core@7.26.10)(eslint@9.22.0)': - dependencies: - '@babel/core': 7.26.10 - '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 - eslint: 9.22.0 - eslint-visitor-keys: 2.1.0 - semver: 6.3.1 - - '@babel/generator@7.26.10': - dependencies: - '@babel/parser': 7.26.10 - '@babel/types': 7.26.10 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 3.1.0 - - '@babel/helper-annotate-as-pure@7.25.9': - dependencies: - '@babel/types': 7.26.10 - - '@babel/helper-compilation-targets@7.26.5': - dependencies: - '@babel/compat-data': 7.26.8 - '@babel/helper-validator-option': 7.25.9 - browserslist: 4.24.4 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-create-class-features-plugin@7.26.9(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-member-expression-to-functions': 7.25.9 - '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/helper-replace-supers': 7.26.5(@babel/core@7.26.10) - '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/traverse': 7.26.10 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/helper-member-expression-to-functions@7.25.9': - dependencies: - '@babel/traverse': 7.26.10 - '@babel/types': 7.26.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.25.9': - dependencies: - '@babel/traverse': 7.26.10 - '@babel/types': 7.26.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.26.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-optimise-call-expression@7.25.9': - dependencies: - '@babel/types': 7.26.10 - - '@babel/helper-plugin-utils@7.26.5': {} - - '@babel/helper-replace-supers@7.26.5(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-member-expression-to-functions': 7.25.9 - '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/traverse': 7.26.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.25.9': - dependencies: - '@babel/traverse': 7.26.10 - '@babel/types': 7.26.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.25.9': {} - - '@babel/helper-validator-identifier@7.25.9': {} - - '@babel/helper-validator-option@7.25.9': {} - - '@babel/helpers@7.26.10': - dependencies: - '@babel/template': 7.26.9 - '@babel/types': 7.26.10 - - '@babel/parser@7.26.10': - dependencies: - '@babel/types': 7.26.10 - - '@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-create-class-features-plugin': 7.26.9(@babel/core@7.26.10) - '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.26.10) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-plugin-utils': 7.26.5 - - '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-plugin-utils': 7.26.5 - - '@babel/plugin-transform-typescript@7.26.8(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.26.9(@babel/core@7.26.10) - '@babel/helper-plugin-utils': 7.26.5 - '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.10) - transitivePeerDependencies: - - supports-color - - '@babel/template@7.26.9': - dependencies: - '@babel/code-frame': 7.26.2 - '@babel/parser': 7.26.10 - '@babel/types': 7.26.10 - - '@babel/traverse@7.26.10': - dependencies: - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.10 - '@babel/parser': 7.26.10 - '@babel/template': 7.26.9 - '@babel/types': 7.26.10 - debug: 4.4.0 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.26.10': - dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - - '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': - dependencies: - '@csstools/css-tokenizer': 3.0.3 - - '@csstools/css-tokenizer@3.0.3': {} - - '@csstools/media-query-list-parser@4.0.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 - - '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.0)': - dependencies: - postcss-selector-parser: 7.1.0 - - '@discourse/lint-configs@2.11.1(ember-template-lint@7.0.1(@babel/core@7.26.10))(eslint@9.22.0)(postcss@8.5.3)(prettier@3.5.3)(stylelint@16.16.0(typescript@5.8.2))': - dependencies: - '@babel/core': 7.26.10 - '@babel/eslint-parser': 7.26.10(@babel/core@7.26.10)(eslint@9.22.0) - '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.26.10) - ember-template-lint: 7.0.1(@babel/core@7.26.10) - eslint: 9.22.0 - eslint-plugin-decorator-position: 6.0.0(@babel/eslint-parser@7.26.10(@babel/core@7.26.10)(eslint@9.22.0))(eslint@9.22.0) - eslint-plugin-ember: 12.5.0(@babel/core@7.26.10)(eslint@9.22.0) - eslint-plugin-import: 2.31.0(eslint@9.22.0) - eslint-plugin-qunit: 8.1.2(eslint@9.22.0) - eslint-plugin-simple-import-sort: 12.1.1(eslint@9.22.0) - eslint-plugin-sort-class-members: 1.21.0(eslint@9.22.0) - globals: 16.0.0 - prettier: 3.5.3 - prettier-plugin-ember-template-tag: 2.0.4(prettier@3.5.3) - stylelint: 16.16.0(typescript@5.8.2) - stylelint-config-standard: 37.0.0(stylelint@16.16.0(typescript@5.8.2)) - stylelint-config-standard-scss: 14.0.0(postcss@8.5.3)(stylelint@16.16.0(typescript@5.8.2)) - typescript: 5.8.2 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - postcss - - supports-color - - '@dual-bundle/import-meta-resolve@4.1.0': {} - - '@ember-data/rfc395-data@0.0.4': {} - - '@eslint-community/eslint-utils@4.5.1(eslint@9.22.0)': - dependencies: - eslint: 9.22.0 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.1': {} - - '@eslint/config-array@0.19.2': - dependencies: - '@eslint/object-schema': 2.1.6 - debug: 4.4.0 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.1.0': {} - - '@eslint/core@0.12.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.0': - dependencies: - ajv: 6.12.6 - debug: 4.4.0 - espree: 10.3.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.22.0': {} - - '@eslint/object-schema@2.1.6': {} - - '@eslint/plugin-kit@0.2.7': - dependencies: - '@eslint/core': 0.12.0 - levn: 0.4.1 - - '@glimmer/env@0.1.7': {} - - '@glimmer/global-context@0.84.3': - dependencies: - '@glimmer/env': 0.1.7 - - '@glimmer/interfaces@0.84.3': - dependencies: - '@simple-dom/interface': 1.4.0 - - '@glimmer/interfaces@0.92.3': - dependencies: - '@simple-dom/interface': 1.4.0 - - '@glimmer/reference@0.84.3': - dependencies: - '@glimmer/env': 0.1.7 - '@glimmer/global-context': 0.84.3 - '@glimmer/interfaces': 0.84.3 - '@glimmer/util': 0.84.3 - '@glimmer/validator': 0.84.3 - - '@glimmer/syntax@0.84.3': - dependencies: - '@glimmer/interfaces': 0.84.3 - '@glimmer/util': 0.84.3 - '@handlebars/parser': 2.0.0 - simple-html-tokenizer: 0.5.11 - - '@glimmer/syntax@0.92.3': - dependencies: - '@glimmer/interfaces': 0.92.3 - '@glimmer/util': 0.92.3 - '@glimmer/wire-format': 0.92.3 - '@handlebars/parser': 2.0.0 - simple-html-tokenizer: 0.5.11 - - '@glimmer/util@0.84.3': - dependencies: - '@glimmer/env': 0.1.7 - '@glimmer/interfaces': 0.84.3 - '@simple-dom/interface': 1.4.0 - - '@glimmer/util@0.92.3': - dependencies: - '@glimmer/env': 0.1.7 - '@glimmer/interfaces': 0.92.3 - - '@glimmer/validator@0.84.3': - dependencies: - '@glimmer/env': 0.1.7 - '@glimmer/global-context': 0.84.3 - - '@glimmer/wire-format@0.92.3': - dependencies: - '@glimmer/interfaces': 0.92.3 - '@glimmer/util': 0.92.3 - - '@handlebars/parser@2.0.0': {} - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.6': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.3.1 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.3.1': {} - - '@humanwhocodes/retry@0.4.2': {} - - '@jridgewell/gen-mapping@0.3.8': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/set-array@1.2.1': {} - - '@jridgewell/sourcemap-codec@1.5.0': {} - - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - - '@keyv/serialize@1.0.3': - dependencies: - buffer: 6.0.3 - - '@lint-todo/utils@13.1.1': - dependencies: - '@types/eslint': 8.56.12 - find-up: 5.0.0 - fs-extra: 9.1.0 - proper-lockfile: 4.1.2 - slash: 3.0.0 - tslib: 2.8.1 - upath: 2.0.1 - - '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': - dependencies: - eslint-scope: 5.1.1 - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - - '@rtsao/scc@1.1.0': {} - - '@simple-dom/interface@1.4.0': {} - - '@sindresorhus/merge-streams@2.3.0': {} - - '@types/eslint@8.56.12': - dependencies: - '@types/estree': 1.0.6 - '@types/json-schema': 7.0.15 - - '@types/estree@1.0.6': {} - - '@types/json-schema@7.0.15': {} - - '@types/json5@0.0.29': {} - - acorn-jsx@5.3.2(acorn@8.14.1): - dependencies: - acorn: 8.14.1 - - acorn@8.14.1: {} - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.0.6 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.9 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - - array-union@2.1.0: {} - - array.prototype.findlastindex@1.2.5: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.9 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.9 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.9 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.9 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - astral-regex@2.0.0: {} - - async-function@1.0.0: {} - - async-promise-queue@1.0.5: - dependencies: - async: 2.6.4 - debug: 2.6.9 - transitivePeerDependencies: - - supports-color - - async@2.6.4: - dependencies: - lodash: 4.17.21 - - at-least-node@1.0.0: {} - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - balanced-match@1.0.2: {} - - balanced-match@2.0.0: {} - - base64-js@1.5.1: {} - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - brace-expansion@1.1.11: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.24.4: - dependencies: - caniuse-lite: 1.0.30001704 - electron-to-chromium: 1.5.118 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.24.4) - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - cacheable@1.8.9: - dependencies: - hookified: 1.8.1 - keyv: 5.3.1 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001704: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.4.1: {} - - ci-info@4.2.0: {} - - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clone@1.0.4: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - colord@2.9.3: {} - - colors@1.4.0: {} - - commander@8.3.0: {} - - concat-map@0.0.1: {} - - content-tag-utils@0.3.1: - dependencies: - content-tag: 3.1.1 - - content-tag@2.0.3: {} - - content-tag@3.1.1: {} - - convert-source-map@2.0.0: {} - - cosmiconfig@9.0.0(typescript@5.8.2): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.8.2 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - css-functions-list@3.2.3: {} - - css-tree@3.1.0: - dependencies: - mdn-data: 2.12.2 - source-map-js: 1.2.1 - - cssesc@3.0.0: {} - - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - date-fns@3.6.0: {} - - debug@2.6.9: - dependencies: - ms: 2.0.0 - - debug@3.2.7: - dependencies: - ms: 2.1.3 - - debug@4.4.0: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - defaults@1.0.4: - dependencies: - clone: 1.0.4 - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - dot-case@3.0.4: - dependencies: - no-case: 3.0.4 - tslib: 2.8.1 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - electron-to-chromium@1.5.118: {} - - ember-eslint-parser@0.5.9(@babel/core@7.26.10)(eslint@9.22.0): - dependencies: - '@babel/core': 7.26.10 - '@babel/eslint-parser': 7.26.10(@babel/core@7.26.10)(eslint@9.22.0) - '@glimmer/syntax': 0.92.3 - content-tag: 2.0.3 - eslint-scope: 7.2.2 - html-tags: 3.3.1 - mathml-tag-names: 2.1.3 - svg-tags: 1.0.0 - transitivePeerDependencies: - - eslint - - ember-rfc176-data@0.3.18: {} - - ember-template-lint@7.0.1(@babel/core@7.26.10): - dependencies: - '@babel/generator': 7.26.10 - '@babel/parser': 7.26.10 - '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.10) - '@babel/plugin-transform-typescript': 7.26.8(@babel/core@7.26.10) - '@babel/traverse': 7.26.10 - '@lint-todo/utils': 13.1.1 - aria-query: 5.3.2 - chalk: 5.4.1 - ci-info: 4.2.0 - content-tag: 3.1.1 - content-tag-utils: 0.3.1 - date-fns: 3.6.0 - ember-template-recast: 6.1.5 - eslint-formatter-kakoune: 1.0.0 - find-up: 7.0.0 - fuse.js: 7.1.0 - get-stdin: 9.0.0 - globby: 14.1.0 - is-glob: 4.0.3 - language-tags: 1.0.9 - micromatch: 4.0.8 - resolve: 1.22.10 - v8-compile-cache: 2.4.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@babel/core' - - supports-color - - ember-template-recast@6.1.5: - dependencies: - '@glimmer/reference': 0.84.3 - '@glimmer/syntax': 0.84.3 - '@glimmer/validator': 0.84.3 - async-promise-queue: 1.0.5 - colors: 1.4.0 - commander: 8.3.0 - globby: 11.1.0 - ora: 5.4.1 - slash: 3.0.0 - tmp: 0.2.3 - workerpool: 6.5.1 - transitivePeerDependencies: - - supports-color - - emoji-regex@8.0.0: {} - - env-paths@2.2.1: {} - - error-ex@1.3.2: - dependencies: - is-arrayish: 0.2.1 - - es-abstract@1.23.9: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-regex: 1.2.1 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-formatter-kakoune@1.0.0: {} - - eslint-import-resolver-node@0.3.9: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.10 - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.22.0): - dependencies: - debug: 3.2.7 - optionalDependencies: - eslint: 9.22.0 - eslint-import-resolver-node: 0.3.9 - transitivePeerDependencies: - - supports-color - - eslint-plugin-decorator-position@6.0.0(@babel/eslint-parser@7.26.10(@babel/core@7.26.10)(eslint@9.22.0))(eslint@9.22.0): - dependencies: - '@babel/core': 7.26.10 - '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.26.10) - '@ember-data/rfc395-data': 0.0.4 - ember-rfc176-data: 0.3.18 - eslint: 9.22.0 - snake-case: 3.0.4 - optionalDependencies: - '@babel/eslint-parser': 7.26.10(@babel/core@7.26.10)(eslint@9.22.0) - transitivePeerDependencies: - - supports-color - - eslint-plugin-ember@12.5.0(@babel/core@7.26.10)(eslint@9.22.0): - dependencies: - '@ember-data/rfc395-data': 0.0.4 - css-tree: 3.1.0 - ember-eslint-parser: 0.5.9(@babel/core@7.26.10)(eslint@9.22.0) - ember-rfc176-data: 0.3.18 - eslint: 9.22.0 - eslint-utils: 3.0.0(eslint@9.22.0) - estraverse: 5.3.0 - lodash.camelcase: 4.3.0 - lodash.kebabcase: 4.1.1 - requireindex: 1.2.0 - snake-case: 3.0.4 - transitivePeerDependencies: - - '@babel/core' - - eslint-plugin-import@2.31.0(eslint@9.22.0): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.5 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.22.0 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.22.0) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-qunit@8.1.2(eslint@9.22.0): - dependencies: - eslint-utils: 3.0.0(eslint@9.22.0) - requireindex: 1.2.0 - transitivePeerDependencies: - - eslint - - eslint-plugin-simple-import-sort@12.1.1(eslint@9.22.0): - dependencies: - eslint: 9.22.0 - - eslint-plugin-sort-class-members@1.21.0(eslint@9.22.0): - dependencies: - eslint: 9.22.0 - - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@7.2.2: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-scope@8.3.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-utils@3.0.0(eslint@9.22.0): - dependencies: - eslint: 9.22.0 - eslint-visitor-keys: 2.1.0 - - eslint-visitor-keys@2.1.0: {} - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.0: {} - - eslint@9.22.0: - dependencies: - '@eslint-community/eslint-utils': 4.5.1(eslint@9.22.0) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.19.2 - '@eslint/config-helpers': 0.1.0 - '@eslint/core': 0.12.0 - '@eslint/eslintrc': 3.3.0 - '@eslint/js': 9.22.0 - '@eslint/plugin-kit': 0.2.7 - '@humanfs/node': 0.16.6 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.2 - '@types/estree': 1.0.6 - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.0 - escape-string-regexp: 4.0.0 - eslint-scope: 8.3.0 - eslint-visitor-keys: 4.2.0 - espree: 10.3.0 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.3.0: - dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) - eslint-visitor-keys: 4.2.0 - - esquery@1.6.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@4.3.0: {} - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-uri@3.0.6: {} - - fastest-levenshtein@1.0.16: {} - - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - - file-entry-cache@10.0.7: - dependencies: - flat-cache: 6.1.7 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - find-up@7.0.0: - dependencies: - locate-path: 7.2.0 - path-exists: 5.0.0 - unicorn-magic: 0.1.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - - flat-cache@6.1.7: - dependencies: - cacheable: 1.8.9 - flatted: 3.3.3 - hookified: 1.8.1 - - flatted@3.3.3: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - - fs-extra@9.1.0: - dependencies: - at-least-node: 1.0.0 - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - - function-bind@1.1.2: {} - - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - fuse.js@7.1.0: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stdin@9.0.0: {} - - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - global-modules@2.0.0: - dependencies: - global-prefix: 3.0.0 - - global-prefix@3.0.0: - dependencies: - ini: 1.3.8 - kind-of: 6.0.3 - which: 1.3.1 - - globals@11.12.0: {} - - globals@14.0.0: {} - - globals@16.0.0: {} - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - - globby@14.1.0: - dependencies: - '@sindresorhus/merge-streams': 2.3.0 - fast-glob: 3.3.3 - ignore: 7.0.3 - path-type: 6.0.0 - slash: 5.1.0 - unicorn-magic: 0.3.0 - - globjoin@0.1.4: {} - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - has-bigints@1.1.0: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hookified@1.8.1: {} - - html-tags@3.3.1: {} - - ieee754@1.2.1: {} - - ignore@5.3.2: {} - - ignore@7.0.3: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - inherits@2.0.4: {} - - ini@1.3.8: {} - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-arrayish@0.2.1: {} - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-callable@1.2.7: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-extglob@2.1.1: {} - - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-function@1.1.0: - dependencies: - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-interactive@1.0.0: {} - - is-map@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-number@7.0.0: {} - - is-plain-object@5.0.0: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.19 - - is-unicode-supported@0.1.0: {} - - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@2.0.5: {} - - isexe@2.0.0: {} - - js-tokens@4.0.0: {} - - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-parse-even-better-errors@2.3.1: {} - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@1.0.2: - dependencies: - minimist: 1.2.8 - - json5@2.2.3: {} - - jsonfile@6.1.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - keyv@5.3.1: - dependencies: - '@keyv/serialize': 1.0.3 - - kind-of@6.0.3: {} - - known-css-properties@0.35.0: {} - - language-subtag-registry@0.3.23: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.23 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lines-and-columns@1.2.4: {} - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - locate-path@7.2.0: - dependencies: - p-locate: 6.0.0 - - lodash.camelcase@4.3.0: {} - - lodash.kebabcase@4.1.1: {} - - lodash.merge@4.6.2: {} - - lodash.truncate@4.4.2: {} - - lodash@4.17.21: {} - - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - - lower-case@2.0.2: - dependencies: - tslib: 2.8.1 - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - math-intrinsics@1.1.0: {} - - mathml-tag-names@2.1.3: {} - - mdn-data@2.12.2: {} - - mdn-data@2.18.0: {} - - meow@13.2.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mimic-fn@2.1.0: {} - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.11 - - minimist@1.2.8: {} - - ms@2.0.0: {} - - ms@2.1.3: {} - - nanoid@3.3.9: {} - - natural-compare@1.4.0: {} - - no-case@3.0.4: - dependencies: - lower-case: 2.0.2 - tslib: 2.8.1 - - node-releases@2.0.19: {} - - normalize-path@3.0.0: {} - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.9 - es-object-atoms: 1.1.1 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.9 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-limit@4.0.0: - dependencies: - yocto-queue: 1.2.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - p-locate@6.0.0: - dependencies: - p-limit: 4.0.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.26.2 - error-ex: 1.3.2 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - path-exists@4.0.0: {} - - path-exists@5.0.0: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - path-type@4.0.0: {} - - path-type@6.0.0: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - possible-typed-array-names@1.1.0: {} - - postcss-media-query-parser@0.2.3: {} - - postcss-resolve-nested-selector@0.1.6: {} - - postcss-safe-parser@7.0.1(postcss@8.5.3): - dependencies: - postcss: 8.5.3 - - postcss-scss@4.0.9(postcss@8.5.3): - dependencies: - postcss: 8.5.3 - - postcss-selector-parser@7.1.0: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-value-parser@4.2.0: {} - - postcss@8.5.3: - dependencies: - nanoid: 3.3.9 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prettier-plugin-ember-template-tag@2.0.4(prettier@3.5.3): - dependencies: - '@babel/core': 7.26.10 - content-tag: 2.0.3 - prettier: 3.5.3 - transitivePeerDependencies: - - supports-color - - prettier@3.5.3: {} - - proper-lockfile@4.1.2: - dependencies: - graceful-fs: 4.2.11 - retry: 0.12.0 - signal-exit: 3.0.7 - - punycode@2.3.1: {} - - queue-microtask@1.2.3: {} - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.23.9 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - requireindex@1.2.0: {} - - resolve-from@4.0.0: {} - - resolve-from@5.0.0: {} - - resolve@1.22.10: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - - retry@0.12.0: {} - - reusify@1.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-buffer@5.2.1: {} - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - semver@6.3.1: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@3.0.7: {} - - signal-exit@4.1.0: {} - - simple-html-tokenizer@0.5.11: {} - - slash@3.0.0: {} - - slash@5.1.0: {} - - slice-ansi@4.0.0: - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - - snake-case@3.0.4: - dependencies: - dot-case: 3.0.4 - tslib: 2.8.1 - - source-map-js@1.2.1: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.23.9 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@3.0.0: {} - - strip-json-comments@3.1.1: {} - - stylelint-config-recommended-scss@14.1.0(postcss@8.5.3)(stylelint@16.16.0(typescript@5.8.2)): - dependencies: - postcss-scss: 4.0.9(postcss@8.5.3) - stylelint: 16.16.0(typescript@5.8.2) - stylelint-config-recommended: 14.0.1(stylelint@16.16.0(typescript@5.8.2)) - stylelint-scss: 6.11.1(stylelint@16.16.0(typescript@5.8.2)) - optionalDependencies: - postcss: 8.5.3 - - stylelint-config-recommended@14.0.1(stylelint@16.16.0(typescript@5.8.2)): - dependencies: - stylelint: 16.16.0(typescript@5.8.2) - - stylelint-config-recommended@15.0.0(stylelint@16.16.0(typescript@5.8.2)): - dependencies: - stylelint: 16.16.0(typescript@5.8.2) - - stylelint-config-standard-scss@14.0.0(postcss@8.5.3)(stylelint@16.16.0(typescript@5.8.2)): - dependencies: - stylelint: 16.16.0(typescript@5.8.2) - stylelint-config-recommended-scss: 14.1.0(postcss@8.5.3)(stylelint@16.16.0(typescript@5.8.2)) - stylelint-config-standard: 36.0.1(stylelint@16.16.0(typescript@5.8.2)) - optionalDependencies: - postcss: 8.5.3 - - stylelint-config-standard@36.0.1(stylelint@16.16.0(typescript@5.8.2)): - dependencies: - stylelint: 16.16.0(typescript@5.8.2) - stylelint-config-recommended: 14.0.1(stylelint@16.16.0(typescript@5.8.2)) - - stylelint-config-standard@37.0.0(stylelint@16.16.0(typescript@5.8.2)): - dependencies: - stylelint: 16.16.0(typescript@5.8.2) - stylelint-config-recommended: 15.0.0(stylelint@16.16.0(typescript@5.8.2)) - - stylelint-scss@6.11.1(stylelint@16.16.0(typescript@5.8.2)): - dependencies: - css-tree: 3.1.0 - is-plain-object: 5.0.0 - known-css-properties: 0.35.0 - mdn-data: 2.18.0 - postcss-media-query-parser: 0.2.3 - postcss-resolve-nested-selector: 0.1.6 - postcss-selector-parser: 7.1.0 - postcss-value-parser: 4.2.0 - stylelint: 16.16.0(typescript@5.8.2) - - stylelint@16.16.0(typescript@5.8.2): - dependencies: - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 - '@csstools/media-query-list-parser': 4.0.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.0) - '@dual-bundle/import-meta-resolve': 4.1.0 - balanced-match: 2.0.0 - colord: 2.9.3 - cosmiconfig: 9.0.0(typescript@5.8.2) - css-functions-list: 3.2.3 - css-tree: 3.1.0 - debug: 4.4.0 - fast-glob: 3.3.3 - fastest-levenshtein: 1.0.16 - file-entry-cache: 10.0.7 - global-modules: 2.0.0 - globby: 11.1.0 - globjoin: 0.1.4 - html-tags: 3.3.1 - ignore: 7.0.3 - imurmurhash: 0.1.4 - is-plain-object: 5.0.0 - known-css-properties: 0.35.0 - mathml-tag-names: 2.1.3 - meow: 13.2.0 - micromatch: 4.0.8 - normalize-path: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.3 - postcss-resolve-nested-selector: 0.1.6 - postcss-safe-parser: 7.0.1(postcss@8.5.3) - postcss-selector-parser: 7.1.0 - postcss-value-parser: 4.2.0 - resolve-from: 5.0.0 - string-width: 4.2.3 - supports-hyperlinks: 3.2.0 - svg-tags: 1.0.0 - table: 6.9.0 - write-file-atomic: 5.0.1 - transitivePeerDependencies: - - supports-color - - typescript - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-hyperlinks@3.2.0: - dependencies: - has-flag: 4.0.0 - supports-color: 7.2.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - svg-tags@1.0.0: {} - - table@6.9.0: - dependencies: - ajv: 8.17.1 - lodash.truncate: 4.4.2 - slice-ansi: 4.0.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - tmp@0.2.3: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - tsconfig-paths@3.15.0: - dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tslib@2.8.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typescript@5.8.2: {} - - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - unicorn-magic@0.1.0: {} - - unicorn-magic@0.3.0: {} - - universalify@2.0.1: {} - - upath@2.0.1: {} - - update-browserslist-db@1.1.3(browserslist@4.24.4): - dependencies: - browserslist: 4.24.4 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - util-deprecate@1.0.2: {} - - v8-compile-cache@2.4.0: {} - - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.0 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.19 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.19: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - - which@1.3.1: - dependencies: - isexe: 2.0.0 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - workerpool@6.5.1: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - write-file-atomic@5.0.1: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 4.1.0 - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@0.1.0: {} - - yocto-queue@1.2.0: {} diff --git a/public/ai-share/highlight.min.js b/public/ai-share/highlight.min.js deleted file mode 100644 index 11daa2d5..00000000 --- a/public/ai-share/highlight.min.js +++ /dev/null @@ -1,1120 +0,0 @@ -/*! - Highlight.js v11.0.1 (git: 1cf31f015d) - (c) 2006-2021 Ivan Sagalaev and other contributors - License: BSD-3-Clause - */ -var hljs=function(){"use strict";var e={exports:{}};function n(e){ -return e instanceof Map?e.clear=e.delete=e.set=()=>{ -throw Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=()=>{ -throw Error("set is read-only") -}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach((t=>{var a=e[t] -;"object"!=typeof a||Object.isFrozen(a)||n(a)})),e} -e.exports=n,e.exports.default=n;var t=e.exports;class a{constructor(e){ -void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} -ignoreMatch(){this.isMatchIgnored=!0}}function i(e){ -return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'") -}function s(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] -;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const r=e=>!!e.kind -;class o{constructor(e,n){ -this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ -this.buffer+=i(e)}openNode(e){if(!r(e))return;let n=e.kind -;n=e.sublanguage?"language-"+n:((e,{prefix:n})=>{if(e.includes(".")){ -const t=e.split(".") -;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") -}return`${n}${e}`})(n,{prefix:this.classPrefix}),this.span(n)}closeNode(e){ -r(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){ -this.buffer+=`<span class="${e}">`}}class l{constructor(){this.rootNode={ -children:[]},this.stack=[this.rootNode]}get top(){ -return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ -this.top.children.push(e)}openNode(e){const n={kind:e,children:[]} -;this.add(n),this.stack.push(n)}closeNode(){ -if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ -for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} -walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ -return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), -n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ -"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ -l._collapse(e)})))}}class c extends l{constructor(e){super(),this.options=e} -addKeyword(e,n){""!==e&&(this.openNode(n),this.addText(e),this.closeNode())} -addText(e){""!==e&&this.add(e)}addSublanguage(e,n){const t=e.root -;t.kind=n,t.sublanguage=!0,this.add(t)}toHTML(){ -return new o(this,this.options).value()}finalize(){return!0}}function d(e){ -return e?"string"==typeof e?e:e.source:null}function g(e){return b("(?=",e,")")} -function u(e){return b("(?:",e,")?")}function b(...e){ -return e.map((e=>d(e))).join("")}function m(...e){return"("+((e=>{ -const n=e[e.length-1] -;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} -})(e).capture?"":"?:")+e.map((e=>d(e))).join("|")+")"}function p(e){ -return RegExp(e.toString()+"|").exec("").length-1} -const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ -;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t -;let a=d(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} -i+=a.substring(0,e.index), -a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], -"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} -const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",N="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",v={ -begin:"\\\\[\\s\\S]",relevance:0},O={scope:"string",begin:"'",end:"'", -illegal:"\\n",contains:[v]},M={scope:"string",begin:'"',end:'"',illegal:"\\n", -contains:[v]},x=(e,n,t={})=>{const a=s({scope:"comment",begin:e,end:n, -contains:[]},t);a.contains.push({scope:"doctag", -begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", -end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) -;const i=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) -;return a.contains.push({begin:b(/[ ]+/,"(",i,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),a -},S=x("//","$"),k=x("/\\*","\\*/"),A=x("#","$");var C=Object.freeze({ -__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:f,UNDERSCORE_IDENT_RE:E, -NUMBER_RE:y,C_NUMBER_RE:N,BINARY_NUMBER_RE:w, -RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", -SHEBANG:(e={})=>{const n=/^#![ ]*\// -;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),s({scope:"meta",begin:n, -end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, -BACKSLASH_ESCAPE:v,APOS_STRING_MODE:O,QUOTE_STRING_MODE:M,PHRASAL_WORDS_MODE:{ -begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ -},COMMENT:x,C_LINE_COMMENT_MODE:S,C_BLOCK_COMMENT_MODE:k,HASH_COMMENT_MODE:A, -NUMBER_MODE:{scope:"number",begin:y,relevance:0},C_NUMBER_MODE:{scope:"number", -begin:N,relevance:0},BINARY_NUMBER_MODE:{scope:"number",begin:w,relevance:0}, -REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{scope:"regexp",begin:/\//, -end:/\/[gimuy]*/,illegal:/\n/,contains:[v,{begin:/\[/,end:/\]/,relevance:0, -contains:[v]}]}]},TITLE_MODE:{scope:"title",begin:f,relevance:0}, -UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0},METHOD_GUARD:{ -begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{ -"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ -n.data._beginMatch!==e[1]&&n.ignoreMatch()}})});function T(e,n){ -"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ -void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ -n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", -e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, -void 0===e.relevance&&(e.relevance=0))}function I(e,n){ -Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function B(e,n){ -if(e.match){ -if(e.begin||e.end)throw Error("begin & end are not supported with match") -;e.begin=e.match,delete e.match}}function L(e,n){ -void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return -;if(e.starts)throw Error("beforeMatch cannot be used with starts") -;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] -})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,g(t.begin)),e.starts={ -relevance:0,contains:[Object.assign(t,{endsParent:!0})] -},e.relevance=0,delete t.beforeMatch -},z=["of","and","for","in","not","or","if","then","parent","list","value"] -;function F(e,n,t="keyword"){const a=Object.create(null) -;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ -Object.assign(a,F(e[t],n,t))})),a;function i(e,t){ -n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") -;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ -return n?Number(n):(e=>z.includes(e.toLowerCase()))(e)?0:1}const U={},P=e=>{ -console.error(e)},K=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ -U[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),U[`${e}/${n}`]=!0) -},H=Error();function G(e,n,{key:t}){let a=0;const i=e[t],s={},r={} -;for(let e=1;e<=n.length;e++)r[e+a]=i[e],s[e+a]=!0,a+=p(n[e-1]) -;e[t]=r,e[t]._emit=s,e[t]._multi=!0}function Z(e){(e=>{ -e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, -delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ -_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope -}),(e=>{if(Array.isArray(e.begin)){ -if(e.skip||e.excludeBegin||e.returnBegin)throw P("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), -H -;if("object"!=typeof e.beginScope||null===e.beginScope)throw P("beginScope must be object"), -H;G(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ -if(Array.isArray(e.end)){ -if(e.skip||e.excludeEnd||e.returnEnd)throw P("skip, excludeEnd, returnEnd not compatible with endScope: {}"), -H -;if("object"!=typeof e.endScope||null===e.endScope)throw P("endScope must be object"), -H;G(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function W(e){ -function n(n,t){return RegExp(d(n),"m"+(e.case_insensitive?"i":"")+(t?"g":""))} -class t{constructor(){ -this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} -addRule(e,n){ -n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), -this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) -;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" -}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex -;const n=this.matcherRe.exec(e);if(!n)return null -;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] -;return n.splice(0,t),Object.assign(n,a)}}class a{constructor(){ -this.rules=[],this.multiRegexes=[], -this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ -if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t -;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), -n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ -return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ -this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ -const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex -;let t=n.exec(e) -;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ -const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} -return t&&(this.regexIndex+=t.position+1, -this.regexIndex===this.count&&this.considerAll()),t}} -if(e.compilerExtensions||(e.compilerExtensions=[]), -e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") -;return e.classNameAliases=s(e.classNameAliases||{}),function t(i,r){const o=i -;if(i.isCompiled)return o -;[R,B,Z,$].forEach((e=>e(i,r))),e.compilerExtensions.forEach((e=>e(i,r))), -i.__beforeBegin=null,[D,I,L].forEach((e=>e(i,r))),i.isCompiled=!0;let l=null -;return"object"==typeof i.keywords&&i.keywords.$pattern&&(i.keywords=Object.assign({},i.keywords), -l=i.keywords.$pattern, -delete i.keywords.$pattern),l=l||/\w+/,i.keywords&&(i.keywords=F(i.keywords,e.case_insensitive)), -o.keywordPatternRe=n(l,!0), -r&&(i.begin||(i.begin=/\B|\b/),o.beginRe=n(i.begin),i.end||i.endsWithParent||(i.end=/\B|\b/), -i.end&&(o.endRe=n(i.end)), -o.terminatorEnd=d(i.end)||"",i.endsWithParent&&r.terminatorEnd&&(o.terminatorEnd+=(i.end?"|":"")+r.terminatorEnd)), -i.illegal&&(o.illegalRe=n(i.illegal)), -i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>s(e,{ -variants:null},n)))),e.cachedVariants?e.cachedVariants:Q(e)?s(e,{ -starts:e.starts?s(e.starts):null -}):Object.isFrozen(e)?s(e):e))("self"===e?i:e)))),i.contains.forEach((e=>{t(e,o) -})),i.starts&&t(i.starts,r),o.matcher=(e=>{const n=new a -;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" -}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" -}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function Q(e){ -return!!e&&(e.endsWithParent||Q(e.starts))}const X=i,V=s,J=Symbol("nomatch") -;var Y=(e=>{const n=Object.create(null),i=Object.create(null),s=[];let r=!0 -;const o="Could not find the language '{}', did you forget to load/include a language module?",l={ -disableAutodetect:!0,name:"Plain text",contains:[]};let d={ -ignoreUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, -languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", -cssSelector:"pre code",languages:null,__emitter:c};function g(e){ -return d.noHighlightRe.test(e)}function u(e,n,t,a){let i="",s="" -;"object"==typeof n?(i=e, -t=n.ignoreIllegals,s=n.language,a=void 0):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), -q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), -s=e,i=n),void 0===t&&(t=!0);const r={code:i,language:s};N("before:highlight",r) -;const o=r.result?r.result:b(r.language,r.code,t,a) -;return o.code=r.code,N("after:highlight",o),o}function b(e,t,i,s){ -const l=Object.create(null);function c(){if(!M.keywords)return void S.addText(k) -;let e=0;M.keywordPatternRe.lastIndex=0;let n=M.keywordPatternRe.exec(k),t="" -;for(;n;){t+=k.substring(e,n.index) -;const i=w.case_insensitive?n[0].toLowerCase():n[0],s=(a=i,M.keywords[a]);if(s){ -const[e,a]=s -;if(S.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(A+=a),e.startsWith("_"))t+=n[0];else{ -const t=w.classNameAliases[e]||e;S.addKeyword(n[0],t)}}else t+=n[0] -;e=M.keywordPatternRe.lastIndex,n=M.keywordPatternRe.exec(k)}var a -;t+=k.substr(e),S.addText(t)}function g(){null!=M.subLanguage?(()=>{ -if(""===k)return;let e=null;if("string"==typeof M.subLanguage){ -if(!n[M.subLanguage])return void S.addText(k) -;e=b(M.subLanguage,k,!0,x[M.subLanguage]),x[M.subLanguage]=e._top -}else e=m(k,M.subLanguage.length?M.subLanguage:null) -;M.relevance>0&&(A+=e.relevance),S.addSublanguage(e._emitter,e.language) -})():c(),k=""}function u(e,n){let t=1;for(;void 0!==n[t];){if(!e._emit[t]){t++ -;continue}const a=w.classNameAliases[e[t]]||e[t],i=n[t] -;a?S.addKeyword(i,a):(k=i,c(),k=""),t++}}function p(e,n){ -return e.scope&&"string"==typeof e.scope&&S.openNode(w.classNameAliases[e.scope]||e.scope), -e.beginScope&&(e.beginScope._wrap?(S.addKeyword(k,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), -k=""):e.beginScope._multi&&(u(e.beginScope,n),k="")),M=Object.create(e,{parent:{ -value:M}}),M}function _(e,n,t){let i=((e,n)=>{const t=e&&e.exec(n) -;return t&&0===t.index})(e.endRe,t);if(i){if(e["on:end"]){const t=new a(e) -;e["on:end"](n,t),t.isMatchIgnored&&(i=!1)}if(i){ -for(;e.endsParent&&e.parent;)e=e.parent;return e}} -if(e.endsWithParent)return _(e.parent,n,t)}function h(e){ -return 0===M.matcher.regexIndex?(k+=e[0],1):(R=!0,0)}function E(e){ -const n=e[0],a=t.substr(e.index),i=_(M,e,a);if(!i)return J;const s=M -;M.endScope&&M.endScope._wrap?(g(), -S.addKeyword(n,M.endScope._wrap)):M.endScope&&M.endScope._multi?(g(), -u(M.endScope,e)):s.skip?k+=n:(s.returnEnd||s.excludeEnd||(k+=n), -g(),s.excludeEnd&&(k=n));do{ -M.scope&&!M.isMultiClass&&S.closeNode(),M.skip||M.subLanguage||(A+=M.relevance), -M=M.parent}while(M!==i.parent) -;return i.starts&&p(i.starts,e),s.returnEnd?0:n.length}let y={};function N(n,s){ -const o=s&&s[0];if(k+=n,null==o)return g(),0 -;if("begin"===y.type&&"end"===s.type&&y.index===s.index&&""===o){ -if(k+=t.slice(s.index,s.index+1),!r){const n=Error(`0 width match regex (${e})`) -;throw n.languageName=e,n.badRule=y.rule,n}return 1} -if(y=s,"begin"===s.type)return(e=>{ -const n=e[0],t=e.rule,i=new a(t),s=[t.__beforeBegin,t["on:begin"]] -;for(const t of s)if(t&&(t(e,i),i.isMatchIgnored))return h(n) -;return t.skip?k+=n:(t.excludeBegin&&(k+=n), -g(),t.returnBegin||t.excludeBegin||(k=n)),p(t,e),t.returnBegin?0:n.length})(s) -;if("illegal"===s.type&&!i){ -const e=Error('Illegal lexeme "'+o+'" for mode "'+(M.scope||"<unnamed>")+'"') -;throw e.mode=M,e}if("end"===s.type){const e=E(s);if(e!==J)return e} -if("illegal"===s.type&&""===o)return 1 -;if(T>1e5&&T>3*s.index)throw Error("potential infinite loop, way more iterations than matches") -;return k+=o,o.length}const w=f(e) -;if(!w)throw P(o.replace("{}",e)),Error('Unknown language: "'+e+'"') -;const v=W(w);let O="",M=s||v;const x={},S=new d.__emitter(d);(()=>{const e=[] -;for(let n=M;n!==w;n=n.parent)n.scope&&e.unshift(n.scope) -;e.forEach((e=>S.openNode(e)))})();let k="",A=0,C=0,T=0,R=!1;try{ -for(M.matcher.considerAll();;){ -T++,R?R=!1:M.matcher.considerAll(),M.matcher.lastIndex=C -;const e=M.matcher.exec(t);if(!e)break;const n=N(t.substring(C,e.index),e) -;C=e.index+n}return N(t.substr(C)),S.closeAllNodes(),S.finalize(),O=S.toHTML(),{ -language:e,value:O,relevance:A,illegal:!1,_emitter:S,_top:M}}catch(n){ -if(n.message&&n.message.includes("Illegal"))return{language:e,value:X(t), -illegal:!0,relevance:0,_illegalBy:{message:n.message,index:C, -context:t.slice(C-100,C+100),mode:n.mode,resultSoFar:O},_emitter:S};if(r)return{ -language:e,value:X(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:M} -;throw n}}function m(e,t){t=t||d.languages||Object.keys(n);const a=(e=>{ -const n={value:X(e),illegal:!1,relevance:0,_top:l,_emitter:new d.__emitter(d)} -;return n._emitter.addText(e),n})(e),i=t.filter(f).filter(y).map((n=>b(n,e,!1))) -;i.unshift(a);const s=i.sort(((e,n)=>{ -if(e.relevance!==n.relevance)return n.relevance-e.relevance -;if(e.language&&n.language){if(f(e.language).supersetOf===n.language)return 1 -;if(f(n.language).supersetOf===e.language)return-1}return 0})),[r,o]=s,c=r -;return c.secondBest=o,c}function p(e){let n=null;const t=(e=>{ -let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" -;const t=d.languageDetectRe.exec(n);if(t){const n=f(t[1]) -;return n||(K(o.replace("{}",t[1])), -K("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} -return n.split(/\s+/).find((e=>g(e)||f(e)))})(e);if(g(t))return -;N("before:highlightElement",{el:e,language:t -}),!d.ignoreUnescapedHTML&&e.children.length>0&&(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), -console.warn("https://github.com/highlightjs/highlight.js/issues/2886"), -console.warn(e)),n=e;const a=n.textContent,s=t?u(a,{language:t,ignoreIllegals:!0 -}):m(a);e.innerHTML=s.value,((e,n,t)=>{const a=n&&i[n]||t -;e.classList.add("hljs"),e.classList.add("language-"+a) -})(e,t,s.language),e.result={language:s.language,re:s.relevance, -relevance:s.relevance},s.secondBest&&(e.secondBest={ -language:s.secondBest.language,relevance:s.secondBest.relevance -}),N("after:highlightElement",{el:e,result:s,text:a})}let _=!1;function h(){ -"loading"!==document.readyState?document.querySelectorAll(d.cssSelector).forEach(p):_=!0 -}function f(e){return e=(e||"").toLowerCase(),n[e]||n[i[e]]} -function E(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ -i[e.toLowerCase()]=n}))}function y(e){const n=f(e) -;return n&&!n.disableAutodetect}function N(e,n){const t=e;s.forEach((e=>{ -e[t]&&e[t](n)}))} -"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ -_&&h()}),!1),Object.assign(e,{highlight:u,highlightAuto:m,highlightAll:h, -highlightElement:p, -highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), -q("10.7.0","Please use highlightElement now."),p(e)),configure:e=>{d=V(d,e)}, -initHighlighting:()=>{ -h(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, -initHighlightingOnLoad:()=>{ -h(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") -},registerLanguage:(t,a)=>{let i=null;try{i=a(e)}catch(e){ -if(P("Language definition for '{}' could not be registered.".replace("{}",t)), -!r)throw e;P(e),i=l} -i.name||(i.name=t),n[t]=i,i.rawDefinition=a.bind(null,e),i.aliases&&E(i.aliases,{ -languageName:t})},unregisterLanguage:e=>{delete n[e] -;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, -listLanguages:()=>Object.keys(n),getLanguage:f,registerAliases:E, -autoDetection:y,inherit:V,addPlugin:e=>{(e=>{ -e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ -e["before:highlightBlock"](Object.assign({block:n.el},n)) -}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ -e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),s.push(e)} -}),e.debugMode=()=>{r=!1},e.safeMode=()=>{r=!0},e.versionString="11.0.1" -;for(const e in C)"object"==typeof C[e]&&t(C[e]);return Object.assign(e,C),e -})({});const ee=e=>({IMPORTANT:{scope:"meta",begin:"!important"},HEXCOLOR:{ -scope:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"}, -ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", -contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ -scope:"number", -begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", -relevance:0} -}),ne=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],te=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],ae=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],ie=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],se=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse(),re=ae.concat(ie) -;var oe="\\.([0-9](_*[0-9])*)",le="[0-9a-fA-F](_*[0-9a-fA-F])*",ce={ -className:"number",variants:[{ -begin:`(\\b([0-9](_*[0-9])*)((${oe})|\\.)?|(${oe}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\b` -},{begin:`\\b([0-9](_*[0-9])*)((${oe})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ -begin:`(${oe})[fFdD]?\\b`},{begin:"\\b([0-9](_*[0-9])*)[fFdD]\\b"},{ -begin:`\\b0[xX]((${le})\\.?|(${le})?\\.(${le}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\b` -},{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${le})[lL]?\\b`},{ -begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], -relevance:0};function de(e,n,t){return-1===t?"":e.replace(n,(a=>de(e,n,t-1)))} -const ge="[A-Za-z$_][0-9A-Za-z$_]*",ue=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],be=["true","false","null","undefined","NaN","Infinity"],me=["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],pe=["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],_e=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],he=["arguments","this","super","console","window","document","localStorage","module","global"],fe=[].concat(_e,me,pe) -;function Ee(e){const n=ge,t={begin:/<[A-Za-z0-9\\._:-]+/, -end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ -const t=e[0].length+e.index,a=e.input[t];"<"!==a?">"===a&&(((e,{after:n})=>{ -const t="</"+e[0].slice(1);return-1!==e.input.indexOf(t,n)})(e,{after:t -})||n.ignoreMatch()):n.ignoreMatch()}},a={$pattern:ge,keyword:ue,literal:be, -built_in:fe,"variable.language":he -},i="\\.([0-9](_?[0-9])*)",s="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",r={ -className:"number",variants:[{ -begin:`(\\b(${s})((${i})|\\.)?|(${i}))[eE][+-]?([0-9](_?[0-9])*)\\b`},{ -begin:`\\b(${s})\\b((${i})\\b|\\.)?|(${i})\\b`},{ -begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{ -begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{ -begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{ -begin:"\\b0[0-7]+n?\\b"}],relevance:0},o={className:"subst",begin:"\\$\\{", -end:"\\}",keywords:a,contains:[]},l={begin:"html`",end:"",starts:{end:"`", -returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,o],subLanguage:"xml"}},c={ -begin:"css`",end:"",starts:{end:"`",returnEnd:!1, -contains:[e.BACKSLASH_ESCAPE,o],subLanguage:"css"}},d={className:"string", -begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,o]},u={className:"comment", -variants:[e.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{ -begin:"(?=@[A-Za-z]+)",relevance:0,contains:[{className:"doctag", -begin:"@[A-Za-z]+"},{className:"type",begin:"\\{",end:"\\}",excludeEnd:!0, -excludeBegin:!0,relevance:0},{className:"variable",begin:n+"(?=\\s*(-)|$)", -endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}] -}),e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE] -},m=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,l,c,d,r,e.REGEXP_MODE] -;o.contains=m.concat({begin:/\{/,end:/\}/,keywords:a,contains:["self"].concat(m) -});const p=[].concat(u,o.contains),_=p.concat([{begin:/\(/,end:/\)/,keywords:a, -contains:["self"].concat(p)}]),h={className:"params",begin:/\(/,end:/\)/, -excludeBegin:!0,excludeEnd:!0,keywords:a,contains:_},f={variants:[{ -match:[/class/,/\s+/,n],scope:{1:"keyword",3:"title.class"}},{ -match:[/extends/,/\s+/,b(n,"(",b(/\./,n),")*")],scope:{1:"keyword", -3:"title.class.inherited"}}]},E={relevance:0, -match:/\b[A-Z][a-z]+([A-Z][a-z]+)*/,className:"title.class",keywords:{ -_:[...me,...pe]}},y={variants:[{match:[/function/,/\s+/,n,/(?=\s*\()/]},{ -match:[/function/,/\s*(?=\()/]}],className:{1:"keyword",3:"title.function"}, -label:"func.def",contains:[h],illegal:/%/},N={ -match:b(/\b/,(w=[..._e,"super"],b("(?!",w.join("|"),")")),n,g(/\(/)), -className:"title.function",relevance:0};var w;const v={ -begin:b(/\./,g(b(n,/(?![0-9A-Za-z$_(])/))),end:n,excludeBegin:!0, -keywords:"prototype",className:"property",relevance:0},O={ -match:[/get|set/,/\s+/,n,/(?=\()/],className:{1:"keyword",3:"title.function"}, -contains:[{begin:/\(\)/},h] -},M="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+e.UNDERSCORE_IDENT_RE+")\\s*=>",x={ -match:[/const|var|let/,/\s+/,n,/\s*/,/=\s*/,g(M)],className:{1:"keyword", -3:"title.function"},contains:[h]};return{name:"Javascript", -aliases:["js","jsx","mjs","cjs"],keywords:a,exports:{PARAMS_CONTAINS:_}, -illegal:/#(?![$_A-z])/,contains:[e.SHEBANG({label:"shebang",binary:"node", -relevance:5}),{label:"use_strict",className:"meta",relevance:10, -begin:/^\s*['"]use (strict|asm)['"]/ -},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,l,c,d,u,r,E,{className:"attr", -begin:n+g(":"),relevance:0},x,{ -begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", -keywords:"return throw case",relevance:0,contains:[u,e.REGEXP_MODE,{ -className:"function",begin:M,returnBegin:!0,end:"\\s*=>",contains:[{ -className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ -className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0, -excludeEnd:!0,keywords:a,contains:_}]}]},{begin:/,/,relevance:0},{match:/\s+/, -relevance:0},{variants:[{begin:"<>",end:"</>"},{begin:t.begin, -"on:begin":t.isTrulyOpeningTag,end:t.end}],subLanguage:"xml",contains:[{ -begin:t.begin,end:t.end,skip:!0,contains:["self"]}]}]},y,{ -beginKeywords:"while if switch catch for"},{ -begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", -returnBegin:!0,label:"func.def",contains:[h,e.inherit(e.TITLE_MODE,{begin:n, -className:"title.function"})]},{match:/\.\.\./,relevance:0},v,{match:"\\$"+n, -relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, -contains:[h]},N,{relevance:0,match:/\b[A-Z][A-Z_]+\b/, -className:"variable.constant"},f,O,{match:/\$[(.]/}]}} -const ye=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),Ne=["Protocol","Type"].map(ye),we=["init","self"].map(ye),ve=["Any","Self"],Oe=["actor","associatedtype","async","await",/as\?/,/as!/,"as","break","case","catch","class","continue","convenience","default","defer","deinit","didSet","do","dynamic","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","lazy","let","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Me=["false","nil","true"],xe=["assignment","associativity","higherThan","left","lowerThan","none","right"],Se=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warn_unqualified_access","#warning"],ke=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Ae=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Ce=m(Ae,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Te=b(Ae,Ce,"*"),Re=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),De=m(Re,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Ie=b(Re,De,"*"),Be=b(/[A-Z]/,De,"*"),Le=["autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Ie,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","testable","UIApplicationMain","unknown","usableFromInline"],$e=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] -;var ze=Object.freeze({__proto__:null,grmr_bash:e=>{const n={},t={begin:/\$\{/, -end:/\}/,contains:["self",{begin:/:-/,contains:[n]}]};Object.assign(n,{ -className:"variable",variants:[{ -begin:b(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},t]});const a={ -className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},i={ -begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, -end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/, -contains:[e.BACKSLASH_ESCAPE,n,a]};a.contains.push(s);const r={begin:/\$\(\(/, -end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,n] -},o=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 -}),l={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, -contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ -name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z._-]+\b/, -keyword:["if","then","else","elif","fi","for","while","in","do","done","case","esac","function"], -literal:["true","false"], -built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp" -},contains:[o,e.SHEBANG(),l,r,e.HASH_COMMENT_MODE,i,s,{className:"",begin:/\\"/ -},{className:"string",begin:/'/,end:/'/},n]}},grmr_c:e=>{ -const n=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] -}),t="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+u(t)+"[a-zA-Z_]\\w*"+u("<[^<>]+>")+")",i={ -className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ -match:/\batomic_[a-z]{3,6}\b/}]},s={className:"string",variants:[{ -begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ -begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", -end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ -begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},r={ -className:"number",variants:[{begin:"\\b(0b[01']+)"},{ -begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" -},{ -begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" -}],relevance:0},o={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ -keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" -},contains:[{begin:/\\\n/,relevance:0},e.inherit(s,{className:"string"}),{ -className:"string",begin:/<.*?>/},n,e.C_BLOCK_COMMENT_MODE]},l={ -className:"title",begin:u(t)+e.IDENT_RE,relevance:0 -},c=u(t)+e.IDENT_RE+"\\s*\\(",d={ -keyword:["asm","auto","break","case","const","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","static","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], -type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","complex","bool","imaginary"], -literal:"true false NULL", -built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" -},g=[o,i,n,e.C_BLOCK_COMMENT_MODE,r,s],b={variants:[{begin:/=/,end:/;/},{ -begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], -keywords:d,contains:g.concat([{begin:/\(/,end:/\)/,keywords:d, -contains:g.concat(["self"]),relevance:0}]),relevance:0},m={ -begin:"("+a+"[\\*&\\s]+)+"+c,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, -keywords:d,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:"decltype\\(auto\\)", -keywords:d,relevance:0},{begin:c,returnBegin:!0,contains:[e.inherit(l,{ -className:"title.function"})],relevance:0},{relevance:0,match:/,/},{ -className:"params",begin:/\(/,end:/\)/,keywords:d,relevance:0, -contains:[n,e.C_BLOCK_COMMENT_MODE,s,r,i,{begin:/\(/,end:/\)/,keywords:d, -relevance:0,contains:["self",n,e.C_BLOCK_COMMENT_MODE,s,r,i]}] -},i,n,e.C_BLOCK_COMMENT_MODE,o]};return{name:"C",aliases:["h"],keywords:d, -disableAutodetect:!0,illegal:"</",contains:[].concat(b,m,g,[o,{ -begin:e.IDENT_RE+"::",keywords:d},{className:"class", -beginKeywords:"enum class struct union",end:/[{;:<>=]/,contains:[{ -beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:o, -strings:s,keywords:d}}},grmr_cpp:e=>{const n=e.COMMENT("//","$",{contains:[{ -begin:/\\\n/}] -}),t="[a-zA-Z_]\\w*::",a="(?!struct)(decltype\\(auto\\)|"+u(t)+"[a-zA-Z_]\\w*"+u("<[^<>]+>")+")",i={ -className:"type",begin:"\\b[a-z\\d_]*_t\\b"},s={className:"string",variants:[{ -begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ -begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", -end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ -begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},r={ -className:"number",variants:[{begin:"\\b(0b[01']+)"},{ -begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" -},{ -begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" -}],relevance:0},o={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ -keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" -},contains:[{begin:/\\\n/,relevance:0},e.inherit(s,{className:"string"}),{ -className:"string",begin:/<.*?>/},n,e.C_BLOCK_COMMENT_MODE]},l={ -className:"title",begin:u(t)+e.IDENT_RE,relevance:0 -},c=u(t)+e.IDENT_RE+"\\s*\\(",d={ -type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t"], -keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","signed","sizeof","static","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","unsigned","using","virtual","volatile","while","xor","xor_eq,"], -literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], -_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] -},m={className:"function.dispatch",relevance:0,keywords:{ -_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] -}, -begin:b(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,e.IDENT_RE,g(/(<[^<>]+>|)\s*\(/)) -},p=[m,o,i,n,e.C_BLOCK_COMMENT_MODE,r,s],_={variants:[{begin:/=/,end:/;/},{ -begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], -keywords:d,contains:p.concat([{begin:/\(/,end:/\)/,keywords:d, -contains:p.concat(["self"]),relevance:0}]),relevance:0},h={className:"function", -begin:"("+a+"[\\*&\\s]+)+"+c,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, -keywords:d,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:"decltype\\(auto\\)", -keywords:d,relevance:0},{begin:c,returnBegin:!0,contains:[l],relevance:0},{ -begin:/::/,relevance:0},{begin:/:/,endsWithParent:!0,contains:[s,r]},{ -relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/,keywords:d, -relevance:0,contains:[n,e.C_BLOCK_COMMENT_MODE,s,r,i,{begin:/\(/,end:/\)/, -keywords:d,relevance:0,contains:["self",n,e.C_BLOCK_COMMENT_MODE,s,r,i]}] -},i,n,e.C_BLOCK_COMMENT_MODE,o]};return{name:"C++", -aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:d,illegal:"</", -classNameAliases:{"function.dispatch":"built_in"}, -contains:[].concat(_,h,m,p,[o,{ -begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array|tuple|optional|variant|function)\\s*<", -end:">",keywords:d,contains:["self",i]},{begin:e.IDENT_RE+"::",keywords:d},{ -match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], -className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ -keyword:["abstract","as","base","break","case","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), -built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], -literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ -begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ -begin:"\\b(0b[01']+)"},{ -begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ -begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" -}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] -},s=e.inherit(i,{illegal:/\n/}),r={className:"subst",begin:/\{/,end:/\}/, -keywords:n},o=e.inherit(r,{illegal:/\n/}),l={className:"string",begin:/\$"/, -end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ -},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ -begin:/\{\{/},{begin:/\}\}/},{begin:'""'},r]},d=e.inherit(c,{illegal:/\n/, -contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) -;r.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], -o.contains=[d,l,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ -illegal:/\n/})];const g={variants:[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] -},u={begin:"<",end:">",contains:[{beginKeywords:"in out"},t] -},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ -begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], -keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, -contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ -begin:"\x3c!--|--\x3e"},{begin:"</?",end:">"}]}] -}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", -end:"$",keywords:{ -keyword:"if else elif endif define undef warning error line region endregion pragma checksum" -}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, -illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" -},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", -relevance:0,end:/[{;=]/,illegal:/[^\s:]/, -contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ -beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, -contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", -begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ -className:"string",begin:/"/,end:/"/}]},{ -beginKeywords:"new return throw await else",relevance:0},{className:"function", -begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0, -end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ -beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", -relevance:0},{begin:e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0, -contains:[e.TITLE_MODE,u],relevance:0},{className:"params",begin:/\(/,end:/\)/, -excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, -contains:[g,a,e.C_BLOCK_COMMENT_MODE] -},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ -const n=ee(e),t=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{name:"CSS", -case_insensitive:!0,illegal:/[=|'\$]/,keywords:{keyframePosition:"from to"}, -classNameAliases:{keyframePosition:"selector-tag"}, -contains:[e.C_BLOCK_COMMENT_MODE,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ -},n.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 -},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 -},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ -begin:":("+ae.join("|")+")"},{begin:"::("+ie.join("|")+")"}]},{ -className:"attribute",begin:"\\b("+se.join("|")+")\\b"},{begin:":",end:"[;}]", -contains:[n.HEXCOLOR,n.IMPORTANT,n.CSS_NUMBER_MODE,...t,{ -begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" -},contains:[{className:"string",begin:/[^)]/,endsWithParent:!0,excludeEnd:!0}] -},{className:"built_in",begin:/[\w-]+(?=\()/}]},{begin:g(/@/),end:"[{;]", -relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ -},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ -$pattern:/[a-z-]+/,keyword:"and or not only",attribute:te.join(" ")},contains:[{ -begin:/[a-z-]+(?=:)/,className:"attribute"},...t,n.CSS_NUMBER_MODE]}]},{ -className:"selector-tag",begin:"\\b("+ne.join("|")+")\\b"}]}},grmr_diff:e=>({ -name:"Diff",aliases:["patch"],contains:[{className:"meta",relevance:10, -match:m(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) -},{className:"comment",variants:[{ -begin:m(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), -end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ -className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, -end:/$/}]}),grmr_go:e=>{const n={ -keyword:["break","default","func","interface","select","case","map","struct","chan","else","goto","package","switch","const","fallthrough","if","range","type","continue","for","import","return","var","go","defer","bool","byte","complex64","complex128","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], -literal:["true","false","iota","nil"], -built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] -};return{name:"Go",aliases:["golang"],keywords:n,illegal:"</", -contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string", -variants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:"`",end:"`"}]},{ -className:"number",variants:[{begin:e.C_NUMBER_RE+"[i]",relevance:1 -},e.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func", -end:"\\s*(\\{|$)",excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params", -begin:/\(/,end:/\)/,keywords:n,illegal:/["']/}]}]}},grmr_ini:e=>{const n={ -className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ -begin:e.NUMBER_RE}]},t=e.COMMENT();t.variants=[{begin:/;/,end:/$/},{begin:/#/, -end:/$/}];const a={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ -begin:/\$\{(.*?)\}/}]},i={className:"literal", -begin:/\bon|off|true|false|yes|no\b/},s={className:"string", -contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ -begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] -},r={begin:/\[/,end:/\]/,contains:[t,i,a,s,n,"self"],relevance:0 -},o=m(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{name:"TOML, also INI", -aliases:["toml"],case_insensitive:!0,illegal:/\S/,contains:[t,{ -className:"section",begin:/\[+/,end:/\]+/},{ -begin:b(o,"(\\s*\\.\\s*",o,")*",g(/\s*=\s*[^#\s]/)),className:"attr",starts:{ -end:/$/,contains:[t,r,i,a,s,n]}}]}},grmr_java:e=>{ -const n="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",t=n+de("(?:<"+n+"~~~(?:\\s*,\\s*"+n+"~~~)*>)?",/~~~/g,2),a={ -keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do"], -literal:["false","true","null"], -type:["char","boolean","long","float","int","byte","short","double"], -built_in:["super","this"]},i={className:"meta",begin:"@"+n,contains:[{ -begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, -end:/\)/,keywords:a,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} -;return{name:"Java",aliases:["jsp"],keywords:a,illegal:/<\/|#/, -contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, -relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ -begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 -},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ -match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,n],className:{ -1:"keyword",3:"title.class"}},{begin:[n,/\s+/,n,/\s+/,/=/],className:{1:"type", -3:"variable",5:"operator"}},{begin:[/record/,/\s+/,n],className:{1:"keyword", -3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ -beginKeywords:"new throw return else",relevance:0},{ -begin:["(?:"+t+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ -2:"title.function"},keywords:a,contains:[{className:"params",begin:/\(/, -end:/\)/,keywords:a,relevance:0, -contains:[i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,ce,e.C_BLOCK_COMMENT_MODE] -},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},ce,i]}},grmr_javascript:Ee, -grmr_json:e=>({name:"JSON",contains:[{className:"attr", -begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{match:/[{}[\],:]/, -className:"punctuation",relevance:0},e.QUOTE_STRING_MODE,{ -beginKeywords:"true false null" -},e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE],illegal:"\\S"}), -grmr_kotlin:e=>{const n={ -keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", -built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", -literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" -},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ -className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},s={className:"string", -variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", -illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, -contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(s);const r={ -className:"meta", -begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" -},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, -end:/\)/,contains:[e.inherit(s,{className:"string"})]}] -},l=ce,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ -variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, -contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], -{name:"Kotlin",aliases:["kt","kts"],keywords:n, -contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", -begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", -begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", -begin:/@\w+/}]}},t,r,o,{className:"function",beginKeywords:"fun",end:"[(]|$", -returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ -begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, -contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin:/</,end:/>/, -keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, -endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, -endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 -},e.C_LINE_COMMENT_MODE,c,r,o,s,e.C_NUMBER_MODE]},c]},{className:"class", -beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, -illegal:"extends implements",contains:[{ -beginKeywords:"public protected internal private constructor" -},e.UNDERSCORE_TITLE_MODE,{className:"type",begin:/</,end:/>/,excludeBegin:!0, -excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/, -excludeBegin:!0,returnEnd:!0},r,o]},s,{className:"meta",begin:"^#!/usr/bin/env", -end:"$",illegal:"\n"},l]}},grmr_less:e=>{ -const n=ee(e),t=re,a="([\\w-]+|@\\{[\\w-]+\\})",i=[],s=[],r=e=>({ -className:"string",begin:"~?"+e+".*?"+e}),o=(e,n,t)=>({className:e,begin:n, -relevance:t}),l={$pattern:/[a-z-]+/,keyword:"and or not only", -attribute:te.join(" ")},c={begin:"\\(",end:"\\)",contains:s,keywords:l, -relevance:0} -;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,r("'"),r('"'),n.CSS_NUMBER_MODE,{ -begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", -excludeEnd:!0} -},n.HEXCOLOR,c,o("variable","@@?[\\w-]+",10),o("variable","@\\{[\\w-]+\\}"),o("built_in","~?`[^`]*?`"),{ -className:"attribute",begin:"[\\w-]+\\s*:",end:":",returnBegin:!0,excludeEnd:!0 -},n.IMPORTANT);const d=s.concat({begin:/\{/,end:/\}/,contains:i}),g={ -beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not" -}].concat(s)},u={begin:a+"\\s*:",returnBegin:!0,end:/[;}]/,relevance:0, -contains:[{begin:/-(webkit|moz|ms|o)-/},{className:"attribute", -begin:"\\b("+se.join("|")+")\\b",end:/(?=:)/,starts:{endsWithParent:!0, -illegal:"[<=$]",relevance:0,contains:s}}]},b={className:"keyword", -begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", -starts:{end:"[;{}]",keywords:l,returnEnd:!0,contains:s,relevance:0}},m={ -className:"variable",variants:[{begin:"@[\\w-]+\\s*:",relevance:15},{ -begin:"@[\\w-]+"}],starts:{end:"[;}]",returnEnd:!0,contains:d}},p={variants:[{ -begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:a,end:/\{/}],returnBegin:!0, -returnEnd:!0,illegal:"[<='$\"]",relevance:0, -contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,g,o("keyword","all\\b"),o("variable","@\\{[\\w-]+\\}"),{ -begin:"\\b("+ne.join("|")+")\\b",className:"selector-tag" -},o("selector-tag",a+"%?",0),o("selector-id","#"+a),o("selector-class","\\."+a,0),o("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ -className:"selector-pseudo",begin:":("+ae.join("|")+")"},{ -className:"selector-pseudo",begin:"::("+ie.join("|")+")"},{begin:/\(/,end:/\)/, -relevance:0,contains:d},{begin:"!important"}]},_={ -begin:`[\\w-]+:(:)?(${t.join("|")})`,returnBegin:!0,contains:[p]} -;return i.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,b,m,_,u,p),{ -name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:i}},grmr_lua:e=>{ -const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] -},i=[e.COMMENT("--(?!\\[=*\\[)","$"),e.COMMENT("--\\[=*\\[",t,{contains:[a], -relevance:10})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, -literal:"true false nil", -keyword:"and break do else elseif end for goto if in local not or repeat return then until while", -built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" -},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", -contains:[e.inherit(e.TITLE_MODE,{ -begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", -begin:"\\(",endsWithParent:!0,contains:i}].concat(i) -},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", -begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ -className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", -contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%<?\^\+\*]/}]},t={className:"string", -begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,n]},a={className:"variable", -begin:/\$\([\w-]+\s/,end:/\)/,keywords:{ -built_in:"subst patsubst strip findstring filter filter-out sort word wordlist firstword lastword dir notdir suffix basename addsuffix addprefix join wildcard realpath abspath error warning shell origin flavor foreach if or and call eval file value" -},contains:[n]},i={begin:"^"+e.UNDERSCORE_IDENT_RE+"\\s*(?=[:+?]?=)"},s={ -className:"section",begin:/^[^\s]+:/,end:/$/,contains:[n]};return{ -name:"Makefile",aliases:["mk","mak","make"],keywords:{$pattern:/[\w-]+/, -keyword:"define endef undefine ifdef ifndef ifeq ifneq else endif include -include sinclude override export unexport private vpath" -},contains:[e.HASH_COMMENT_MODE,n,t,a,i,{className:"meta",begin:/^\.PHONY:/, -end:/$/,keywords:{$pattern:/[\.\w]+/,keyword:".PHONY"}},s]}},grmr_xml:e=>{ -const n=b(/[A-Z_]/,u(/[A-Z0-9_.-]*:/),/[A-Z0-9_.-]*/),t={className:"symbol", -begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},a={begin:/\s/,contains:[{ -className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]},i=e.inherit(a,{ -begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{className:"string" -}),r=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),o={endsWithParent:!0, -illegal:/</,relevance:0,contains:[{className:"attr",begin:/[A-Za-z0-9._:-]+/, -relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string", -endsParent:!0,variants:[{begin:/"/,end:/"/,contains:[t]},{begin:/'/,end:/'/, -contains:[t]},{begin:/[^\s"'=<>`]+/}]}]}]};return{name:"HTML, XML", -aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], -case_insensitive:!0,contains:[{className:"meta",begin:/<![a-z]/,end:/>/, -relevance:10,contains:[a,r,s,i,{begin:/\[/,end:/\]/,contains:[{className:"meta", -begin:/<![a-z]/,end:/>/,contains:[a,i,r,s]}]}]},e.COMMENT(/<!--/,/-->/,{ -relevance:10}),{begin:/<!\[CDATA\[/,end:/\]\]>/,relevance:10},t,{ -className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag", -begin:/<style(?=\s|>)/,end:/>/,keywords:{name:"style"},contains:[o],starts:{ -end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", -begin:/<script(?=\s|>)/,end:/>/,keywords:{name:"script"},contains:[o],starts:{ -end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ -className:"tag",begin:/<>|<\/>/},{className:"tag", -begin:b(/</,g(b(n,m(/\/>/,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name", -begin:n,relevance:0,starts:o}]},{className:"tag",begin:b(/<\//,g(b(n,/>/))), -contains:[{className:"name",begin:n,relevance:0},{begin:/>/,relevance:0, -endsParent:!0}]}]}},grmr_markdown:e=>{const n={begin:/<\/?[A-Za-z_]/,end:">", -subLanguage:"xml",relevance:0},t={variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0 -},{begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, -relevance:2},{begin:b(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), -relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ -begin:/\[.+?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{ -className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, -returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", -excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", -end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], -variants:[{begin:/_{2}/,end:/_{2}/},{begin:/\*{2}/,end:/\*{2}/}]},i={ -className:"emphasis",contains:[],variants:[{begin:/\*(?!\*)/,end:/\*/},{ -begin:/_(?!_)/,end:/_/,relevance:0}]};a.contains.push(i),i.contains.push(a) -;let s=[n,t] -;return a.contains=a.contains.concat(s),i.contains=i.contains.concat(s), -s=s.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ -className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:s},{ -begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", -contains:s}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", -end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:s, -end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ -begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ -begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", -contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ -begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ -className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ -className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}},grmr_objectivec:e=>{ -const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, -keyword:["@interface","@class","@protocol","@implementation"]};return{ -name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], -keywords:{$pattern:n, -keyword:["int","float","while","char","export","sizeof","typedef","const","struct","for","union","unsigned","long","volatile","static","bool","mutable","if","do","return","goto","void","enum","else","break","extern","asm","case","short","default","double","register","explicit","signed","typename","this","switch","continue","wchar_t","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","super","unichar","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], -literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], -built_in:["BOOL","dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"] -},illegal:"</",contains:[{className:"built_in", -begin:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+" -},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{ -className:"string",variants:[{begin:'@"',end:'"',illegal:"\\n", -contains:[e.BACKSLASH_ESCAPE]}]},{className:"meta",begin:/#\s*[a-z]+\b/,end:/$/, -keywords:{ -keyword:"if else elif endif define undef warning error line pragma ifdef ifndef include" -},contains:[{begin:/\\\n/,relevance:0},e.inherit(e.QUOTE_STRING_MODE,{ -className:"string"}),{className:"string",begin:/<.*?>/,end:/$/,illegal:"\\n" -},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", -begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, -contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, -relevance:0}]}},grmr_perl:e=>{const n=/[dualxmsipngr]{0,12}/,t={ -$pattern:/[\w.]+/, -keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" -},a={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:t},i={begin:/->\{/, -end:/\}/},s={variants:[{begin:/\$\d/},{ -begin:b(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") -},{begin:/[$%@][^\s\w{]/,relevance:0}] -},r=[e.BACKSLASH_ESCAPE,a,s],o=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],l=(e,t,a="\\1")=>{ -const i="\\1"===a?a:b(a,t) -;return b(b("(?:",e,")"),t,/(?:\\.|[^\\\/])*?/,i,/(?:\\.|[^\\\/])*?/,a,n) -},c=(e,t,a)=>b(b("(?:",e,")"),t,/(?:\\.|[^\\\/])*?/,a,n),d=[s,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ -endsWithParent:!0}),i,{className:"string",contains:r,variants:[{ -begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", -end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ -begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", -relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", -contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", -contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ -begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", -begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", -relevance:0},{ -begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", -keywords:"split return print reverse grep",relevance:0, -contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ -begin:l("s|tr|y",m(...o,{capture:!0}))},{begin:l("s|tr|y","\\(","\\)")},{ -begin:l("s|tr|y","\\[","\\]")},{begin:l("s|tr|y","\\{","\\}")}],relevance:2},{ -className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ -begin:c("(?:m|qr)?",/\//,/\//)},{begin:c("m|qr",m(...o,{capture:!0}),/\1/)},{ -begin:c("m|qr",/\(/,/\)/)},{begin:c("m|qr",/\[/,/\]/)},{ -begin:c("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", -end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ -begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", -subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] -}];return a.contains=d,i.contains=d,{name:"Perl",aliases:["pl","pm"],keywords:t, -contains:d}},grmr_php:e=>{const n={className:"variable", -begin:"\\$+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(?![A-Za-z0-9])(?![$])"},t={ -className:"meta",variants:[{begin:/<\?php/,relevance:10},{begin:/<\?[=]?/},{ -begin:/\?>/}]},a={className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/, -end:/\}/}]},i=e.inherit(e.APOS_STRING_MODE,{illegal:null -}),s=e.inherit(e.QUOTE_STRING_MODE,{illegal:null, -contains:e.QUOTE_STRING_MODE.contains.concat(a)}),r=e.END_SAME_AS_BEGIN({ -begin:/<<<[ \t]*(\w+)\n/,end:/[ \t]*(\w+)\b/, -contains:e.QUOTE_STRING_MODE.contains.concat(a)}),o={className:"string", -contains:[e.BACKSLASH_ESCAPE,t],variants:[e.inherit(i,{begin:"b'",end:"'" -}),e.inherit(s,{begin:'b"',end:'"'}),s,i,r]},l={className:"number",variants:[{ -begin:"\\b0b[01]+(?:_[01]+)*\\b"},{begin:"\\b0o[0-7]+(?:_[0-7]+)*\\b"},{ -begin:"\\b0x[\\da-f]+(?:_[\\da-f]+)*\\b"},{ -begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:e[+-]?\\d+)?" -}],relevance:0},c={ -keyword:"__CLASS__ __DIR__ __FILE__ __FUNCTION__ __LINE__ __METHOD__ __NAMESPACE__ __TRAIT__ die echo exit include include_once print require require_once array abstract and as binary bool boolean break callable case catch class clone const continue declare default do double else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum eval extends final finally float for foreach from global goto if implements instanceof insteadof int integer interface isset iterable list match|0 mixed new object or private protected public real return string switch throw trait try unset use var void while xor yield", -literal:"false null true", -built_in:"Error|0 AppendIterator ArgumentCountError ArithmeticError ArrayIterator ArrayObject AssertionError BadFunctionCallException BadMethodCallException CachingIterator CallbackFilterIterator CompileError Countable DirectoryIterator DivisionByZeroError DomainException EmptyIterator ErrorException Exception FilesystemIterator FilterIterator GlobIterator InfiniteIterator InvalidArgumentException IteratorIterator LengthException LimitIterator LogicException MultipleIterator NoRewindIterator OutOfBoundsException OutOfRangeException OuterIterator OverflowException ParentIterator ParseError RangeException RecursiveArrayIterator RecursiveCachingIterator RecursiveCallbackFilterIterator RecursiveDirectoryIterator RecursiveFilterIterator RecursiveIterator RecursiveIteratorIterator RecursiveRegexIterator RecursiveTreeIterator RegexIterator RuntimeException SeekableIterator SplDoublyLinkedList SplFileInfo SplFileObject SplFixedArray SplHeap SplMaxHeap SplMinHeap SplObjectStorage SplObserver SplObserver SplPriorityQueue SplQueue SplStack SplSubject SplSubject SplTempFileObject TypeError UnderflowException UnexpectedValueException UnhandledMatchError ArrayAccess Closure Generator Iterator IteratorAggregate Serializable Stringable Throwable Traversable WeakReference WeakMap Directory __PHP_Incomplete_Class parent php_user_filter self static stdClass" -};return{case_insensitive:!0,keywords:c, -contains:[e.HASH_COMMENT_MODE,e.COMMENT("//","$",{contains:[t] -}),e.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}] -}),e.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0, -keywords:"__halt_compiler"}),t,{className:"keyword",begin:/\$this\b/},n,{ -begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function", -relevance:0,beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0, -illegal:"[$%\\[]",contains:[{beginKeywords:"use"},e.UNDERSCORE_TITLE_MODE,{ -begin:"=>",endsParent:!0},{className:"params",begin:"\\(",end:"\\)", -excludeBegin:!0,excludeEnd:!0,keywords:c, -contains:["self",n,e.C_BLOCK_COMMENT_MODE,o,l]}]},{className:"class",variants:[{ -beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", -illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ -beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ -beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, -contains:[e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",relevance:0,end:";", -contains:[e.UNDERSCORE_TITLE_MODE]},o,l]}},grmr_php_template:e=>({ -name:"PHP template",subLanguage:"xml",contains:[{begin:/<\?(php|=)?/,end:/\?>/, -subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"', -end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},e.inherit(e.APOS_STRING_MODE,{ -illegal:null,className:null,contains:null,skip:!0 -}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null, -skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text",aliases:["text","txt"], -disableAutodetect:!0}),grmr_python:e=>{const n={$pattern:/[A-Za-z]\w+|__\w+__/, -keyword:["and","as","assert","async","await","break","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"], -built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], -literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], -type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] -},t={className:"meta",begin:/^(>>>|\.\.\.) /},a={className:"subst",begin:/\{/, -end:/\}/,keywords:n,illegal:/#/},i={begin:/\{\{/,relevance:0},s={ -className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ -begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, -contains:[e.BACKSLASH_ESCAPE,t],relevance:10},{ -begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, -contains:[e.BACKSLASH_ESCAPE,t],relevance:10},{ -begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, -contains:[e.BACKSLASH_ESCAPE,t,i,a]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, -end:/"""/,contains:[e.BACKSLASH_ESCAPE,t,i,a]},{begin:/([uU]|[rR])'/,end:/'/, -relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ -begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, -end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, -contains:[e.BACKSLASH_ESCAPE,i,a]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, -contains:[e.BACKSLASH_ESCAPE,i,a]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] -},r="[0-9](_?[0-9])*",o=`(\\b(${r}))?\\.(${r})|\\b(${r})\\.`,l={ -className:"number",relevance:0,variants:[{ -begin:`(\\b(${r})|(${o}))[eE][+-]?(${r})[jJ]?\\b`},{begin:`(${o})[jJ]?`},{ -begin:"\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?\\b"},{ -begin:"\\b0[bB](_?[01])+[lL]?\\b"},{begin:"\\b0[oO](_?[0-7])+[lL]?\\b"},{ -begin:"\\b0[xX](_?[0-9a-fA-F])+[lL]?\\b"},{begin:`\\b(${r})[jJ]\\b`}]},c={ -className:"comment",begin:g(/# type:/),end:/$/,keywords:n,contains:[{ -begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},d={ -className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, -end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n, -contains:["self",t,l,s,e.HASH_COMMENT_MODE]}]};return a.contains=[s,l,t],{ -name:"Python",aliases:["py","gyp","ipython"],keywords:n, -illegal:/(<\/|->|\?)|=>/,contains:[t,l,{begin:/\bself\b/},{beginKeywords:"if", -relevance:0},s,c,e.HASH_COMMENT_MODE,{match:[/def/,/\s+/,f],scope:{1:"keyword", -3:"title.function"},contains:[d]},{variants:[{ -match:[/class/,/\s+/,f,/\s*/,/\(\s*/,f,/\s*\)/]},{match:[/class/,/\s+/,f]}], -scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ -className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[l,d,s]}]}}, -grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta",starts:{ -end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ -begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ -const n=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,t=m(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),a=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,i=m(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) -;return{name:"R",keywords:{$pattern:n, -keyword:"function if in break next repeat else for while", -literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", -built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" -},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",begin:"@examples", -starts:{contains:[{begin:/\n/},{begin:/#'\s*(?=@[a-zA-Z]+)/,endsParent:!0},{ -begin:/#'/,end:/$/,excludeBegin:!0}]}},{scope:"doctag",begin:"@param",end:/$/, -contains:[{scope:"variable",variants:[{begin:n},{begin:/`(?:\\.|[^`\\])+`/}], -endsParent:!0}]},{scope:"doctag",begin:/@[a-zA-Z]+/},{scope:"keyword", -begin:/\\[a-zA-Z]+/}]}),e.HASH_COMMENT_MODE,{scope:"string", -contains:[e.BACKSLASH_ESCAPE],variants:[e.END_SAME_AS_BEGIN({ -begin:/[rR]"(-*)\(/,end:/\)(-*)"/}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/, -end:/\}(-*)"/}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ -}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ -}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ -}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', -relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ -1:"operator",2:"number"},match:[a,t]},{scope:{1:"operator",2:"number"}, -match:[/%[^%]*%/,t]},{scope:{1:"punctuation",2:"number"},match:[i,t]},{scope:{ -2:"number"},match:[/[^a-zA-Z0-9._]|^/,t]}]},{scope:{3:"operator"}, -match:[n,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:a},{ -match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:i},{begin:"`",end:"`", -contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ -const n="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",t={ -keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor __FILE__", -built_in:"proc lambda",literal:"true false nil"},a={className:"doctag", -begin:"@[A-Za-z]+"},i={begin:"#<",end:">"},s=[e.COMMENT("#","$",{contains:[a] -}),e.COMMENT("^=begin","^=end",{contains:[a],relevance:10 -}),e.COMMENT("^__END__","\\n$")],r={className:"subst",begin:/#\{/,end:/\}/, -keywords:t},o={className:"string",contains:[e.BACKSLASH_ESCAPE,r],variants:[{ -begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/, -end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{ -begin:/%[qQwWx]?</,end:/>/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/, -end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{ -begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{ -begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ -begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ -begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ -begin:b(/<<[-~]?'?/,g(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), -contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, -contains:[e.BACKSLASH_ESCAPE,r]})]}]},l="[0-9](_?[0-9])*",c={className:"number", -relevance:0,variants:[{ -begin:`\\b([1-9](_?[0-9])*|0)(\\.(${l}))?([eE][+-]?(${l})|r)?i?\\b`},{ -begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" -},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ -begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ -begin:"\\b0(_?[0-7])+r?i?\\b"}]},d={className:"params",begin:"\\(",end:"\\)", -endsParent:!0,keywords:t},u=[o,{className:"class",beginKeywords:"class module", -end:"$|;",illegal:/=/,contains:[e.inherit(e.TITLE_MODE,{ -begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|!)?"}),{begin:"<\\s*",contains:[{ -begin:"("+e.IDENT_RE+"::)?"+e.IDENT_RE,relevance:0}]}].concat(s)},{ -className:"function",begin:b(/def\s+/,g(n+"\\s*(\\(|;|$)")),relevance:0, -keywords:"def",end:"$|;",contains:[e.inherit(e.TITLE_MODE,{begin:n -}),d].concat(s)},{begin:e.IDENT_RE+"::"},{className:"symbol", -begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", -begin:":(?!\\s)",contains:[o,{begin:n}],relevance:0},c,{className:"variable", -begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ -className:"params",begin:/\|/,end:/\|/,relevance:0,keywords:t},{ -begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{ -className:"regexp",contains:[e.BACKSLASH_ESCAPE,r],illegal:/\n/,variants:[{ -begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(", -end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}] -}].concat(i,s),relevance:0}].concat(i,s);r.contains=u,d.contains=u;const m=[{ -begin:/^\s*=>/,starts:{end:"$",contains:u}},{className:"meta", -begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", -starts:{end:"$",contains:u}}];return s.unshift(i),{name:"Ruby", -aliases:["rb","gemspec","podspec","thor","irb"],keywords:t,illegal:/\/\*/, -contains:[e.SHEBANG({binary:"ruby"})].concat(m).concat(s).concat(u)}}, -grmr_rust:e=>{const n={className:"title.function.invoke",relevance:0, -begin:b(/\b/,/(?!let\b)/,e.IDENT_RE,g(/\s*\(/)) -},t="([ui](8|16|32|64|128|size)|f(32|64))?",a=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","panic!","file!","format!","format_args!","include_bin!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"] -;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?", -type:["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"], -keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"], -literal:["true","false","Some","None","Ok","Err"],built_in:a},illegal:"</", -contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"] -}),e.inherit(e.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{ -className:"string",variants:[{begin:/b?r(#*)"(.|\n)*?"\1(?!#)/},{ -begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol", -begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{ -begin:"\\b0b([01_]+)"+t},{begin:"\\b0o([0-7_]+)"+t},{ -begin:"\\b0x([A-Fa-f0-9_]+)"+t},{ -begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)"+t}],relevance:0},{ -begin:[/fn/,/\s+/,e.UNDERSCORE_IDENT_RE],className:{1:"keyword", -3:"title.function"}},{className:"meta",begin:"#!?\\[",end:"\\]",contains:[{ -className:"string",begin:/"/,end:/"/}]},{ -begin:[/let/,/\s+/,/(?:mut\s+)?/,e.UNDERSCORE_IDENT_RE],className:{1:"keyword", -3:"keyword",4:"variable"}},{ -begin:[/for/,/\s+/,e.UNDERSCORE_IDENT_RE,/\s+/,/in/],className:{1:"keyword", -3:"variable",5:"keyword"}},{begin:[/type/,/\s+/,e.UNDERSCORE_IDENT_RE], -className:{1:"keyword",3:"title.class"}},{ -begin:[/(?:trait|enum|struct|union|impl|for)/,/\s+/,e.UNDERSCORE_IDENT_RE], -className:{1:"keyword",3:"title.class"}},{begin:e.IDENT_RE+"::",keywords:{ -keyword:"Self",built_in:a}},{className:"punctuation",begin:"->"},n]}}, -grmr_scss:e=>{const n=ee(e),t=ie,a=ae,i="@[a-z-]+",s={className:"variable", -begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"};return{name:"SCSS",case_insensitive:!0, -illegal:"[=/|']",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{ -className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ -className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 -},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", -begin:"\\b("+ne.join("|")+")\\b",relevance:0},{className:"selector-pseudo", -begin:":("+a.join("|")+")"},{className:"selector-pseudo", -begin:"::("+t.join("|")+")"},s,{begin:/\(/,end:/\)/,contains:[n.CSS_NUMBER_MODE] -},{className:"attribute",begin:"\\b("+se.join("|")+")\\b"},{ -begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" -},{begin:":",end:";", -contains:[s,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT] -},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ -begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, -keyword:"and or not only",attribute:te.join(" ")},contains:[{begin:i, -className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" -},s,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE]}]}}, -grmr_shell:e=>({name:"Shell Session",aliases:["console","shellsession"], -contains:[{className:"meta",begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{ -end:/[^\\](?=\s*$)/,subLanguage:"bash"}}]}),grmr_sql:e=>{ -const n=e.COMMENT("--","$"),t=["true","false","unknown"],a=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],i=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],r=i,o=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!i.includes(e))),l={ -begin:b(/\b/,m(...r),/\s*\(/),relevance:0,keywords:{built_in:r}};return{ -name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ -$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t -;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) -})(o,{when:e=>e.length<3}),literal:t,type:a, -built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] -},contains:[{begin:m(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, -keyword:o.concat(s),literal:t,type:a}},{className:"type", -begin:m("double precision","large object","with timezone","without timezone") -},l,{className:"variable",begin:/@[a-z0-9]+/},{className:"string",variants:[{ -begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/,contains:[{ -begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,n,{className:"operator", -begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}, -grmr_swift:e=>{const n={match:/\s+/,relevance:0},t=e.COMMENT("/\\*","\\*/",{ -contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={match:[/\./,m(...Ne,...we)], -className:{2:"keyword"}},s={match:b(/\./,m(...Oe)),relevance:0 -},r=Oe.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ -className:"keyword", -match:m(...Oe.filter((e=>"string"!=typeof e)).concat(ve).map(ye),...we)}]},l={ -$pattern:m(/\b\w+/,/#\w+/),keyword:r.concat(Se),literal:Me},c=[i,s,o],d=[{ -match:b(/\./,m(...ke)),relevance:0},{className:"built_in", -match:b(/\b/,m(...ke),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ -className:"operator",relevance:0,variants:[{match:Te},{match:`\\.(\\.|${Ce})+`}] -}],_="([0-9a-fA-F]_*)+",h={className:"number",relevance:0,variants:[{ -match:"\\b(([0-9]_*)+)(\\.(([0-9]_*)+))?([eE][+-]?(([0-9]_*)+))?\\b"},{ -match:`\\b0x(${_})(\\.(${_}))?([pP][+-]?(([0-9]_*)+))?\\b`},{ -match:/\b0o([0-7]_*)+\b/},{match:/\b0b([01]_*)+\b/}]},f=(e="")=>({ -className:"subst",variants:[{match:b(/\\/,e,/[0\\tnr"']/)},{ -match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}]}),E=(e="")=>({className:"subst", -match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/)}),y=(e="")=>({className:"subst", -label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/}),N=(e="")=>({begin:b(e,/"""/), -end:b(/"""/,e),contains:[f(e),E(e),y(e)]}),w=(e="")=>({begin:b(e,/"/), -end:b(/"/,e),contains:[f(e),y(e)]}),v={className:"string", -variants:[N(),N("#"),N("##"),N("###"),w(),w("#"),w("##"),w("###")]},O={ -match:b(/`/,Ie,/`/)},M=[O,{className:"variable",match:/\$\d+/},{ -className:"variable",match:`\\$${De}+`}],x=[{match:/(@|#)available/, -className:"keyword",starts:{contains:[{begin:/\(/,end:/\)/,keywords:$e, -contains:[...p,h,v]}]}},{className:"keyword",match:b(/@/,m(...Le))},{ -className:"meta",match:b(/@/,Ie)}],S={match:g(/\b[A-Z]/),relevance:0,contains:[{ -className:"type", -match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,De,"+") -},{className:"type",match:Be,relevance:0},{match:/[?!]+/,relevance:0},{ -match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,g(Be)),relevance:0}]},k={ -begin:/</,end:/>/,keywords:l,contains:[...a,...c,...x,u,S]};S.contains.push(k) -;const A={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ -match:b(Ie,/\s*:/),keywords:"_|0",relevance:0 -},...a,...c,...d,...p,h,v,...M,...x,S]},C={begin:/</,end:/>/,contains:[...a,S] -},T={begin:/\(/,end:/\)/,keywords:l,contains:[{ -begin:m(g(b(Ie,/\s*:/)),g(b(Ie,/\s+/,Ie,/\s*:/))),end:/:/,relevance:0, -contains:[{className:"keyword",match:/\b_\b/},{className:"params",match:Ie}] -},...a,...c,...p,h,v,...x,S,A],endsParent:!0,illegal:/["']/},R={ -match:[/func/,/\s+/,m(O.match,Ie,Te)],className:{1:"keyword",3:"title.function" -},contains:[C,T,n],illegal:[/\[/,/%/]},D={ -match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, -contains:[C,T,n],illegal:/\[|%/},I={match:[/operator/,/\s+/,Te],className:{ -1:"keyword",3:"title"}},B={begin:[/precedencegroup/,/\s+/,Be],className:{ -1:"keyword",3:"title"},contains:[S],keywords:[...xe,...Me],end:/}/} -;for(const e of v.variants){const n=e.contains.find((e=>"interpol"===e.label)) -;n.keywords=l;const t=[...c,...d,...p,h,v,...M];n.contains=[...t,{begin:/\(/, -end:/\)/,contains:["self",...t]}]}return{name:"Swift",keywords:l, -contains:[...a,R,D,{beginKeywords:"struct protocol class extension enum actor", -end:"\\{",excludeEnd:!0,keywords:l,contains:[e.inherit(e.TITLE_MODE,{ -className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c] -},I,B,{beginKeywords:"import",end:/$/,contains:[...a],relevance:0 -},...c,...d,...p,h,v,...M,...x,S,A]}},grmr_typescript:e=>{const n={$pattern:ge, -keyword:ue.concat(["type","namespace","typedef","interface","public","private","protected","implements","declare","abstract","readonly"]), -literal:be, -built_in:fe.concat(["any","void","number","boolean","string","object","never","enum"]), -"variable.language":he},t={className:"meta",begin:"@[A-Za-z$_][0-9A-Za-z$_]*" -},a=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) -;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t) -},i=Ee(e) -;return Object.assign(i.keywords,n),i.exports.PARAMS_CONTAINS.push(t),i.contains=i.contains.concat([t,{ -beginKeywords:"namespace",end:/\{/,excludeEnd:!0},{beginKeywords:"interface", -end:/\{/,excludeEnd:!0,keywords:"interface extends" -}]),a(i,"shebang",e.SHEBANG()),a(i,"use_strict",{className:"meta",relevance:10, -begin:/^\s*['"]use strict['"]/ -}),i.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(i,{ -name:"TypeScript",aliases:["ts","tsx"]}),i},grmr_vbnet:e=>{ -const n=/\d{1,2}\/\d{1,2}\/\d{4}/,t=/\d{4}-\d{1,2}-\d{1,2}/,a=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,i=/\d{1,2}(:\d{1,2}){1,2}/,s={ -className:"literal",variants:[{begin:b(/# */,m(t,n),/ *#/)},{ -begin:b(/# */,i,/ *#/)},{begin:b(/# */,a,/ *#/)},{ -begin:b(/# */,m(t,n),/ +/,m(a,i),/ *#/)}]},r=e.COMMENT(/'''/,/$/,{contains:[{ -className:"doctag",begin:/<\/?/,end:/>/}]}),o=e.COMMENT(null,/$/,{variants:[{ -begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]});return{name:"Visual Basic .NET", -aliases:["vb"],case_insensitive:!0,classNameAliases:{label:"symbol"},keywords:{ -keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", -built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", -type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", -literal:"true false nothing"}, -illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ -className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, -end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, -variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ -},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ -begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ -className:"label",begin:/^\w+:/},r,o,{className:"meta", -begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, -end:/$/,keywords:{ -keyword:"const disable else elseif enable end externalsource if region then"}, -contains:[o]}]}},grmr_yaml:e=>{ -const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ -className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ -},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", -variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ -variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),s={ -end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},r={begin:/\{/, -end:/\}/,contains:[s],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", -contains:[s],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ -begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ -begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", -relevance:10},{className:"string", -begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ -begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, -relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", -begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t -},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", -begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", -relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ -className:"number", -begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" -},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},r,o,a],c=[...l] -;return c.pop(),c.push(i),s.contains=c,{name:"YAML",case_insensitive:!0, -aliases:["yml"],contains:l}}});const Fe=Y;for(const e of Object.keys(ze)){ -const n=e.replace("grmr_","");Fe.registerLanguage(n,ze[e])}return Fe}() -;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file diff --git a/public/ai-share/share.css b/public/ai-share/share.css deleted file mode 100644 index e36d0460..00000000 --- a/public/ai-share/share.css +++ /dev/null @@ -1,640 +0,0 @@ -:root { - --shadow-header: 0 2px 30px -1px rgba(0, 0, 0, 0.05); - --shadow-content: 0 10px 20px -1px rgba(0, 0, 0, 0.05); - - --primary: #333; - --primary-50: #f7f8fa; - --primary-100: #eff2f5; - --primary-200: #e4e9ee; - --primary-300: #c9d2dd; - --primary-400: #aebccb; - --primary-500: #93a5ba; - --primary-600: #778fa9; - --primary-700: #5b7490; - --primary-800: #4e627a; - --primary-900: #344252; - - --secondary: #fff; - - --tertiary: #08c; - - /* colors from the default dark discourse color scheme */ - @media (prefers-color-scheme: dark) { - --primary: #dddddd; - --primary-50: #282828; - --primary-100: #2c2c2c; - --primary-200: #313131; - --primary-300: #585858; - --primary-400: #7a7a7a; - --primary-500: #858585; - --primary-600: #909090; - --primary-700: #9b9b9b; - --primary-800: #a6a6a6; - --primary-900: #c7c7c7; - - --secondary: #222222; - - --tertiary: #099dd7; - } - - --border-radius-large: 1em; - --border-radius-small: 0.33em; - - --font-up-6: 2.296em; - --font-up-5: 2em; - --font-up-4: 1.7511em; - --font-up-3: 1.5157em; - --font-up-2: 1.3195em; - --font-up-1: 1.1487em; - --font-0: 1em; - --font-down-1: 0.8706em; - --font-down-2: 0.7579em; - --font-down-3: 0.6599em; - --font-down-4: 0.5745em; - --font-down-5: 0.5em; - --font-down-6: 0.4355em; -} - -* { /* reset */ - box-sizing: border-box; -} - -body { - font-family: Söhne, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, Helvetica Neue, Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; - margin: 0; - font-size: 1.125em; /* root scale */ - line-height: 1.8; - color: var(--primary); - background: var(--primary-50); -} - -.site-header { - display: grid; - grid-template-columns: 1fr auto 1fr; - grid-template-areas: "logo site llm"; - position: sticky; - top: 0; - padding: 0 1em; - align-items: center; - background-color: var(--secondary); - box-shadow: var(--shadow-header); - z-index: 2; - @media screen and (max-width: 768px) { - grid-template-areas: "logo site" "logo llm"; - font-size: var(--font-down-1); - padding-bottom: 0.25em; - gap: 0 1em; - align-items: start; - grid-template-columns: auto 1fr; - grid-template-rows: auto 1fr; - } - @media screen and (max-width: 500px) { - position: relative; - grid-template-areas: "logo" "site" "llm"; - } -} - -.ai-logo { - grid-area: logo -} - -.ai-logo .logo { - height: 3.75em; - width: auto; - padding: 0.5em 0; - @media screen and (max-width: 768px) { - padding-bottom: 0; - max-width: 180px; - } -} - -@media (prefers-color-scheme: dark) { - .d-logo-black { - fill: var(--primary); - } -} - -@media (prefers-reduced-motion: no-preference) { - .ai-logo:hover #d-sparkles { - animation: d-rainbow-sparkles 1.5s forwards ease-in-out; - } -} - -@keyframes d-rainbow-sparkles { - - 0% { - fill: #f15d22; - } - 33% { - fill: #fff9ae; - } - 66% { - fill: #00a94f; - } - 100% { - fill: #00aeef; - } -} - -.site-title { - grid-area: site; - font-size: var(--font-up-2); - color: var(--primary); - padding: 0 1em; - transition: color 0.25s ease-in-out; - text-decoration: none; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-weight: bold; - @media screen and (max-width: 768px) { - margin-top: 0.8em; - line-height: 1.2; - text-align: right; - padding: 0; - } - @media screen and (max-width: 500px) { - font-size: var(--font-up-3); - text-align: left; - margin-top: 0; - } -} - -.llm-name { - grid-area: llm; - font-size: var(--font-down-1); - padding: 0.5em 1em; - text-decoration: none; - text-align: right; - line-height: 1.2; - color: var(--primary-600); - @media screen and (max-width: 768px) { - padding: 0; - } - @media screen and (max-width: 500px) { - text-align: left; - margin-top: 0; - margin-bottom: 1.25em; - } -} - -section { - max-width: 900px; - margin: 4em auto 8em; - background-color: var(--secondary); - border-radius: var(--border-radius-large); - box-shadow: var(--shadow-content); - @media screen and (max-width: 768px) { - margin-top: 0; - border-radius: 0; - margin-bottom: 0; - box-shadow: none; - } -} - -.page-header { - text-align: center; - line-height: 1.2; - padding: 3em 2.5em; - @media screen and (max-width: 768px) { - padding: 2em 1em; - text-align: left; - } - @media screen and (max-width: 500px) { - padding: 1em; - } -} - -.page-header h1 { - margin: 0; - font-size: var(--font-up-5); - font-weight: normal; - @media screen and (max-width: 768px) { - font-size: var(--font-up-3); - } -} - -.page-header time { - display: block; - color: var(--primary-500); - margin-top: 0.5em; - @media screen and (max-width: 768px) { - font-size: var(--font-down-1); - } -} - -article.post { - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - transition: border-color 0.25s ease-in-out; - @media screen and (max-width: 768px) { - border-top-color: var(--primary-100); - border-bottom-color: transparent; - } -} - -@media screen and (min-width: 769px) { - article.post:hover { - border-top-color: var(--primary-100); - border-bottom-color: var(--primary-100); - } - article.post:last-of-type:hover { - border-bottom-color: transparent; - } -} - -pre, code { - font-family: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Courier New", monospace; - font-size: var(--font-down-1); - background-color: var(--primary-50); - font-size: inherit; - padding: 0; - background-color: transparent; -} - -pre { - border-radius: var(--border-radius-small); - overflow-x: auto; -} - -code { - padding: 0.2em 0.4em; - border-radius: var(--border-radius-small); - background: var(--primary-50); -} - -code.hljs { - background-color: var(--primary-50); - -} - -h1 h2 h3 h4 h5 h6 { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin-bottom: 0.5em; -} - -.post { - padding: 1.5em 3em 1.5em; - @media screen and (max-width: 768px) { - padding: 1em; - } -} - -.post__header { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: start; - margin-bottom: 0.5em; - gap: 0 0.5em; -} - -.post__user { - font-weight: bold; - color: var(--primary); -} - -.post__date, .post__persona { - font-size: 0.8em; -} - -.post__persona { - color: var(--primary-600); -} - -.post__content { - line-height: 1.4; - color: var(--primary-800); - overflow: hidden; -} - -.post__content img { - max-width: 100%; -} - -.post__content p:first-child { - margin-top: 0; -} - -.post__content p:last-child { - margin-bottom: 0.4em; -} - -.post__content blockquote p:last-child { - margin-bottom: 0; -} - -.post__date { - color: var(--primary-500); - margin-left: auto; - display: none; /* hiding for now */ -} - -summary { - padding: 1em; - border-radius: var(--border-radius-small); - cursor: pointer; - color: var(--tertiary); - outline: none; -} - -summary:hover { - background-color: var(--primary-100); -} - -details { - background-color: var(--primary-50); - border-radius: var(--border-radius-small); - transition: background-color 0.3s ease; - padding: 0.5em; -} - -details[open] { - padding-bottom: 0.75em; -} - -details > *:not(summary) { - padding: 0 1em; -} - -.post__content details p:last-child { - margin-bottom: 0; -} - -details p { - margin-top: 10px; - color: var(--primary-800); -} - -details[open] summary { - color: var(--tertiary); - border-radius: var(--border-radius-small) var(--border-radius-small) 0 0; -} - -a { - text-decoration: none; - color: var(--tertiary); - transition: color 0.25s ease-in-out; -} - -a:hover { - color: var(--tertiary); - text-decoration: underline; -} - -ol { - list-style-type: decimal; -} - -ul { - list-style-type: disc; -} - -@media screen and (max-width: 500px) { - ol, ul { - padding-left: 1.33em; - } -} - -li { - margin-bottom: 0.75em; - line-height: 1.6; -} - -li:last-child { - margin-bottom: 0; -} - -li, p { - overflow-wrap: break-word; - word-wrap: break-word; - word-break: break-word; -} - -.lightbox-wrapper img { - width: auto; - height: auto; -} - -aside .title, -blockquote { - background-color: var(--primary-50); - border-left: 6px solid var(--primary-200); - padding: 0.5em 1em; - margin: 1em 0; -} - -aside .title + blockquote { - margin-top: 0; -} - -aside .title { - margin: 0; - display: flex; - align-items: center; -} - -aside .title .avatar { - border-radius: 100%; - margin-right: 0.5em; -} - -.lightbox-wrapper .meta .filename, -.lightbox-wrapper .meta svg, -.lightbox-wrapper .meta .informations { - display: none; -} - -.lightbox-wrapper .meta .filename { - overflow-wrap: anywhere; -} - -/* - custom code highlighting for dark mode support... - overrides highlightjs (https://highlightjs.org/) CSS colors. -*/ - -.hljs{ - --hljs-red: #c63c1b; - --hljs-salmon: #ec8972; - --hljs-lime: #4dff4d; - --hljs-green: #090; - --hljs-sky: #47c2ff; - - @media (prefers-color-scheme: dark) { - --hljs-red: #af3719; - --hljs-salmon: #c7705c; - --hljs-lime: #3fcf3f; - --hljs-green: #048504; - --hljs-sky: #379ccf; - } - - --hljs-blue: var(--tertiary); /* already has a dark variant at root */ - - background:var(--primary-50); - color:var(--primary); -} - -pre code.hljs{ - display:block; - overflow-x:auto; - padding:1em -} - -code.hljs{ - padding:3px 5px -} - -.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{ - font-weight:700 -} - -.hljs-emphasis{ - font-style:italic -} -.hljs-strong{ - font-weight:700 -} - -.hljs-comment{ - color: var(--hljs-comment); -} - -.hljs-punctuation,.hljs-tag{ - color:var(--primary-900); -} - -.hljs-tag .hljs-attr,.hljs-tag .hljs-name{ - color:var(--primary-900); -} - -.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{ - color: var(--hljs-red); -} - -.hljs-section,.hljs-title{ - color: var(--hljs-red); - font-weight:700 -} - -.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{ - color:var(--hljs-salmon); -} - -.hljs-literal{ - color: var(--hljs-lime); -} - -.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{ - color:var(--hljs-green); -} - -.hljs-meta{ - color:var(--hljs-blue); -} - -.hljs-meta .hljs-string{ - color:var(--hljs-sky); -} - -aside.onebox { - border: 4px solid var(--primary-200); - padding: 1em; - font-size: 0.9em; -} - -aside.onebox .source { - display: flex; - align-items: center; - gap: 0.5em; - font-size: 0.9em; - line-height: 1; - margin-bottom: 1em; -} - -aside.onebox .source a { - color: var(--primary-700); -} - -aside.onebox .site-icon { - width: 1em; - height: 1em; -} - -.onebox-body { - display: grid; - grid-template-areas: "avatar title" "avatar body"; - grid-template-columns: auto 1fr; - grid-template-rows: auto 1fr; - align-items: start; -} - -aside.onebox h3 { - margin: 0 0 0.25em 0; - grid-area: title; -} - -.onebox-avatar { - width: 5.5em; - height: auto; - grid-area: avatar; - margin: .5em 1.5em 0 0; - object-fit: cover; -} - -.onebox-body p { - grid-area: body; - margin-top: 0.5em; - font-size: 0.95em; -} - -.lazy-video-container { - position: relative; -} - -.lazy-video-container a { - display: block; - width: 100%; -} - -.lazy-video-container img { - width: 100%; - object-fit: cover; -} - -.lazy-video-container:hover:before{ - opacity: 1; -} - -.lazy-video-container:before { - /* video play button */ - content:""; - position: absolute; - display: block; - background-size: contain; - background-repeat: no-repeat; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 384 512'%3E%3C!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--%3E%3Cpath d='M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z' fill='white' /%3E%3C/svg%3E"); - z-index: 2; - width: 5em; - height: 6.25em; - left: 0; - right: 0; - top: 32%; - pointer-events: none; - margin-left: auto; - margin-right: auto; - opacity: 0.85; - transition: opacity 0.25s ease-in-out; -} - -.ai-artifact iframe { - height: 600px; - max-height: 600px; -} - -.ai-artifact-controls { - display: flex; - justify-content: space-between; -} - diff --git a/spec/configuration/feature_spec.rb b/spec/configuration/feature_spec.rb deleted file mode 100644 index 1ae63f41..00000000 --- a/spec/configuration/feature_spec.rb +++ /dev/null @@ -1,226 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Configuration::Feature do - fab!(:llm_model) - fab!(:ai_persona) { Fabricate(:ai_persona, default_llm_id: llm_model.id) } - - def allow_configuring_setting(&block) - DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) { block.call } - end - - before { enable_current_plugin } - - describe "#llm_model" do - context "when persona is not found" do - it "returns nil when persona_id is invalid" do - ai_feature = - described_class.new( - "topic_summaries", - "ai_summarization_persona", - DiscourseAi::Configuration::Module::SUMMARIZATION_ID, - DiscourseAi::Configuration::Module::SUMMARIZATION, - ) - - SiteSetting.ai_summarization_persona = 999_999 - expect(ai_feature.llm_models).to eq([]) - end - end - - context "with summarization module" do - let(:ai_feature) do - described_class.new( - "topic_summaries", - "ai_summarization_persona", - DiscourseAi::Configuration::Module::SUMMARIZATION_ID, - DiscourseAi::Configuration::Module::SUMMARIZATION, - ) - end - - it "returns the configured llm model" do - SiteSetting.ai_summarization_persona = ai_persona.id - allow_configuring_setting { SiteSetting.ai_summarization_model = "custom:#{llm_model.id}" } - expect(ai_feature.llm_models).to eq([llm_model]) - end - end - - context "with AI helper module" do - let(:ai_feature) do - described_class.new( - "proofread", - "ai_helper_proofreader_persona", - DiscourseAi::Configuration::Module::AI_HELPER_ID, - DiscourseAi::Configuration::Module::AI_HELPER, - ) - end - - it "returns the persona's default llm when no specific helper model is set" do - SiteSetting.ai_helper_proofreader_persona = ai_persona.id - SiteSetting.ai_helper_model = "" - - expect(ai_feature.llm_models).to eq([llm_model]) - end - end - - context "with translation module" do - fab!(:translation_model) { Fabricate(:llm_model) } - - let(:ai_feature) do - described_class.new( - "locale_detector", - "ai_translation_locale_detector_persona", - DiscourseAi::Configuration::Module::TRANSLATION_ID, - DiscourseAi::Configuration::Module::TRANSLATION, - ) - end - - it "uses translation model when configured" do - SiteSetting.ai_translation_locale_detector_persona = ai_persona.id - ai_persona.update!(default_llm_id: nil) - allow_configuring_setting do - SiteSetting.ai_translation_model = "custom:#{translation_model.id}" - end - - expect(ai_feature.llm_models).to eq([translation_model]) - end - end - end - - describe "#enabled?" do - it "returns true when no enabled_by_setting is specified" do - ai_feature = - described_class.new( - "topic_summaries", - "ai_summarization_persona", - DiscourseAi::Configuration::Module::SUMMARIZATION_ID, - DiscourseAi::Configuration::Module::SUMMARIZATION, - ) - - expect(ai_feature.enabled?).to be true - end - - it "respects the enabled_by_setting when specified" do - ai_feature = - described_class.new( - "gists", - "ai_summary_gists_persona", - DiscourseAi::Configuration::Module::SUMMARIZATION_ID, - DiscourseAi::Configuration::Module::SUMMARIZATION, - enabled_by_setting: "ai_summary_gists_enabled", - ) - - SiteSetting.ai_summary_gists_enabled = false - expect(ai_feature.enabled?).to be false - - SiteSetting.ai_summary_gists_enabled = true - expect(ai_feature.enabled?).to be true - end - end - - describe ".bot_features" do - fab!(:bot_llm) { Fabricate(:llm_model, enabled_chat_bot: true) } - fab!(:non_bot_llm) { Fabricate(:llm_model, enabled_chat_bot: false) } - fab!(:chat_persona) do - Fabricate( - :ai_persona, - default_llm_id: bot_llm.id, - allow_chat_channel_mentions: true, - allow_chat_direct_messages: false, - ) - end - fab!(:dm_persona) do - Fabricate( - :ai_persona, - default_llm_id: bot_llm.id, - allow_chat_channel_mentions: false, - allow_chat_direct_messages: true, - ) - end - fab!(:topic_persona) do - Fabricate( - :ai_persona, - default_llm_id: bot_llm.id, - allow_topic_mentions: true, - allow_personal_messages: false, - ) - end - fab!(:pm_persona) do - Fabricate(:ai_persona, allow_topic_mentions: false, allow_personal_messages: true) - end - fab!(:inactive_persona) do - Fabricate( - :ai_persona, - enabled: false, - allow_chat_channel_mentions: false, - allow_chat_direct_messages: false, - allow_topic_mentions: false, - allow_personal_messages: true, - ) - end - - let(:bot_feature) { described_class.bot_features.first } - - it "returns bot features with correct configuration" do - expect(bot_feature.name).to eq("bot") - expect(bot_feature.persona_setting).to be_nil - expect(bot_feature.module_id).to eq(DiscourseAi::Configuration::Module::BOT_ID) - expect(bot_feature.module_name).to eq(DiscourseAi::Configuration::Module::BOT) - end - - it "returns only LLMs with enabled_chat_bot" do - expect(bot_feature.llm_models).to contain_exactly(bot_llm) - expect(bot_feature.llm_models).not_to include(non_bot_llm) - end - - it "returns only personas with at least one bot permission enabled" do - expected_ids = [chat_persona.id, dm_persona.id, topic_persona.id, pm_persona.id] - AiPersona.where("id not in (:ids)", ids: expected_ids).update_all(enabled: false) - expect(bot_feature.persona_ids).to match_array(expected_ids) - expect(bot_feature.persona_ids).not_to include(inactive_persona.id) - end - - it "includes personas with multiple permissions enabled" do - multi_permission_persona = - Fabricate( - :ai_persona, - enabled: true, - default_llm_id: bot_llm.id, - allow_chat_channel_mentions: true, - allow_chat_direct_messages: true, - allow_topic_mentions: true, - allow_personal_messages: true, - ) - - expect(bot_feature.persona_ids).to include(multi_permission_persona.id) - end - end - - describe "#persona_ids" do - it "returns the persona id from site settings" do - ai_feature = - described_class.new( - "topic_summaries", - "ai_summarization_persona", - DiscourseAi::Configuration::Module::SUMMARIZATION_ID, - DiscourseAi::Configuration::Module::SUMMARIZATION, - ) - - SiteSetting.ai_summarization_persona = ai_persona.id - expect(ai_feature.persona_ids).to eq([ai_persona.id]) - end - end - - describe ".find_features_using" do - it "returns all features using a specific persona" do - SiteSetting.ai_summarization_persona = ai_persona.id - SiteSetting.ai_helper_proofreader_persona = ai_persona.id - SiteSetting.ai_translation_locale_detector_persona = 999 - - features = described_class.find_features_using(persona_id: ai_persona.id) - - expect(features.map(&:name)).to include("topic_summaries", "proofread") - expect(features.map(&:name)).not_to include("locale_detector") - end - end -end diff --git a/spec/configuration/llm_enumerator_spec.rb b/spec/configuration/llm_enumerator_spec.rb deleted file mode 100644 index 7eea717f..00000000 --- a/spec/configuration/llm_enumerator_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Configuration::LlmEnumerator do - fab!(:fake_model) - fab!(:llm_model) - fab!(:seeded_model) - fab!(:automation) do - Fabricate(:automation, script: "llm_report", name: "some automation", enabled: true) - end - - before { enable_current_plugin } - - describe "#values_for_serialization" do - it "returns an array for that can be used for serialization" do - fake_model.destroy! - - expect(described_class.values_for_serialization).to eq( - [ - { - id: llm_model.id, - name: llm_model.display_name, - vision_enabled: llm_model.vision_enabled, - }, - ], - ) - - expect( - described_class.values_for_serialization(allowed_seeded_llm_ids: [seeded_model.id.to_s]), - ).to contain_exactly( - { - id: seeded_model.id, - name: seeded_model.display_name, - vision_enabled: seeded_model.vision_enabled, - }, - { - id: llm_model.id, - name: llm_model.display_name, - vision_enabled: llm_model.vision_enabled, - }, - ) - end - end - - describe "#global_usage" do - it "returns a hash of Llm models in use globally" do - SiteSetting.ai_helper_model = "custom:#{fake_model.id}" - SiteSetting.ai_helper_enabled = true - expect(described_class.global_usage).to eq(fake_model.id => [{ type: :ai_helper }]) - end - - it "returns information about automation rules" do - automation.fields.create!( - component: "text", - name: "model", - metadata: { - value: "custom:#{fake_model.id}", - }, - target: "script", - ) - - usage = described_class.global_usage - - expect(usage).to eq( - { fake_model.id => [{ type: :automation, name: "some automation", id: automation.id }] }, - ) - end - end -end diff --git a/spec/configuration/llm_validator_spec.rb b/spec/configuration/llm_validator_spec.rb deleted file mode 100644 index 89c73c57..00000000 --- a/spec/configuration/llm_validator_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Configuration::LlmValidator do - before { enable_current_plugin } - - describe "#valid_value?" do - context "when the parent module is enabled and we try to reset the selected model" do - before do - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - end - - it "returns false and displays an error message" do - validator = described_class.new(name: :ai_summarization_model) - - value = validator.valid_value?("") - - expect(value).to eq(false) - expect(validator.error_message).to include("ai_summarization_enabled") - end - end - end -end diff --git a/spec/configuration/spam_detection_validator_spec.rb b/spec/configuration/spam_detection_validator_spec.rb deleted file mode 100644 index 4da7c2ca..00000000 --- a/spec/configuration/spam_detection_validator_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Configuration::SpamDetectionValidator do - let(:validator) { described_class.new } - - before { enable_current_plugin } - - it "always returns true if setting the value to false" do - expect(validator.valid_value?("f")).to eq(true) - end - - context "when a moderation setting exists" do - fab!(:llm_model) - before { AiModerationSetting.create!(setting_type: "spam", llm_model_id: llm_model.id) } - - it "returns true if a moderation setting for spam exists" do - expect(validator.valid_value?("t")).to eq(true) - end - end - - context "when no moderation setting exists" do - it "returns false if a moderation setting for spam does not exist" do - expect(validator.valid_value?("t")).to eq(false) - end - - it "returns an error message when no moderation setting exists" do - expect(validator.error_message).to eq( - I18n.t("discourse_ai.spam_detection.configuration_missing"), - ) - end - end -end diff --git a/spec/db/migrate/20241031041242_migrate_sentiment_classification_result_format_spec.rb b/spec/db/migrate/20241031041242_migrate_sentiment_classification_result_format_spec.rb deleted file mode 100644 index 5d46ff43..00000000 --- a/spec/db/migrate/20241031041242_migrate_sentiment_classification_result_format_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" -require Rails.root.join( - "plugins/discourse-ai/db/post_migrate/20241031041242_migrate_sentiment_classification_result_format", - ) - -RSpec.describe MigrateSentimentClassificationResultFormat do - let(:connection) { ActiveRecord::Base.connection } - - before do - enable_current_plugin - connection.execute(<<~SQL) - INSERT INTO classification_results (model_used, classification, created_at, updated_at) VALUES - ('sentiment', '{"neutral": 65, "negative": 20, "positive": 14}', NOW(), NOW()), - ('emotion', '{"sadness": 10, "surprise": 15, "fear": 5, "anger": 20, "joy": 30, "disgust": 8, "neutral": 10}', NOW(), NOW()); - SQL - end - - after { connection.execute("DELETE FROM classification_results") } - - describe "#up" do - before { described_class.new.up } - - it "migrates sentiment classifications correctly" do - sentiment_result = connection.execute(<<~SQL).first - SELECT * FROM classification_results - WHERE model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest'; - SQL - - expected_sentiment = { "neutral" => 0.65, "negative" => 0.20, "positive" => 0.14 } - - expect(JSON.parse(sentiment_result["classification"])).to eq(expected_sentiment) - end - - it "migrates emotion classifications correctly" do - emotion_result = connection.execute(<<~SQL).first - SELECT * FROM classification_results - WHERE model_used = 'j-hartmann/emotion-english-distilroberta-base'; - SQL - - expected_emotion = { - "sadness" => 0.10, - "surprise" => 0.15, - "fear" => 0.05, - "anger" => 0.20, - "joy" => 0.30, - "disgust" => 0.08, - "neutral" => 0.10, - } - - expect(JSON.parse(emotion_result["classification"])).to eq(expected_emotion) - end - end -end diff --git a/spec/db/migrate/20250125162658_fix_broken_open_ai_embeddings_config_spec.rb b/spec/db/migrate/20250125162658_fix_broken_open_ai_embeddings_config_spec.rb deleted file mode 100644 index 5ba29227..00000000 --- a/spec/db/migrate/20250125162658_fix_broken_open_ai_embeddings_config_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" -require Rails.root.join( - "plugins/discourse-ai/db/migrate/20250125162658_fix_broken_open_ai_embeddings_config", - ) - -RSpec.describe FixBrokenOpenAiEmbeddingsConfig do - let(:connection) { ActiveRecord::Base.connection } - - def store_setting(name, val) - connection.execute <<~SQL - INSERT INTO site_settings(name, data_type, value, created_at, updated_at) - VALUES ('#{name}', 3, '#{val}', NOW(), NOW()) - SQL - end - - def configured_model_id - DB.query_single( - "SELECT value FROM site_settings WHERE name = 'ai_embeddings_selected_model'", - ).first - end - - before { enable_current_plugin } - - describe "#up" do - context "when embeddings are already configured" do - fab!(:embedding_definition) - - before { store_setting("ai_embeddings_selected_model", embedding_definition.id) } - - it "does nothing" do - subject.up - - expect(configured_model_id).to eq(embedding_definition.id.to_s) - end - end - - context "when there is no previous config" do - it "does nothing" do - subject.up - - expect(configured_model_id).to be_blank - end - end - - context "when things are not fully configured" do - before do - store_setting("ai_embeddings_model", "text-embedding-3-large") - store_setting("ai_openai_api_key", "") - end - - it "does nothing" do - subject.up - - expect(configured_model_id).to be_blank - end - end - - context "when we have a configuration that previously failed to copy" do - before do - store_setting("ai_embeddings_model", "text-embedding-3-large") - store_setting("ai_openai_api_key", "123") - end - - it "copies the config" do - subject.up - - embedding_def = EmbeddingDefinition.last - - expect(configured_model_id).to eq(embedding_def.id.to_s) - end - end - end -end diff --git a/spec/db/migrate/20250127145305_clean_unused_embedding_search_indexes_spec.rb b/spec/db/migrate/20250127145305_clean_unused_embedding_search_indexes_spec.rb deleted file mode 100644 index d89a91d0..00000000 --- a/spec/db/migrate/20250127145305_clean_unused_embedding_search_indexes_spec.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" -require Rails.root.join( - "plugins/discourse-ai/db/migrate/20250127145305_clean_unused_embedding_search_indexes", - ) - -RSpec.describe CleanUnusedEmbeddingSearchIndexes do - let(:connection) { ActiveRecord::Base.connection } - - before { enable_current_plugin } - - describe "#up" do - before do - # Copied from 20241008054440_create_binary_indexes_for_embeddings - %w[topics posts document_fragments].each do |type| - # our supported embeddings models IDs and dimensions - [ - [1, 768], - [2, 1536], - [3, 1024], - [4, 1024], - [5, 768], - [6, 1536], - [7, 2000], - [8, 1024], - ].each { |model_id, dimensions| connection.execute <<-SQL } - CREATE INDEX IF NOT EXISTS ai_#{type}_embeddings_#{model_id}_1_search_bit ON ai_#{type}_embeddings - USING hnsw ((binary_quantize(embeddings)::bit(#{dimensions})) bit_hamming_ops) - WHERE model_id = #{model_id} AND strategy_id = 1; - SQL - end - end - - let(:all_idx_names) do - %w[topics posts document_fragments].reduce([]) do |memo, type| - (1..8).each { |model_id| memo << "ai_#{type}_embeddings_#{model_id}_1_search_bit" } - - memo - end - end - - context "when there are no embedding definitions" do - it "removes all indexes" do - subject.up - - remaininig_idxs = - DB.query_single( - "SELECT indexname FROM pg_indexes WHERE indexname IN (:names)", - names: all_idx_names, - ) - - expect(remaininig_idxs).to be_empty - end - end - - context "when there is an embedding definition with the same dimensions" do - fab!(:embedding_def) { Fabricate(:embedding_definition, id: 2, dimensions: 1536) } - - it "keeps the matching index and removes the rest" do - expected_model_idxs = - %w[topics posts document_fragments].reduce([]) do |memo, type| - memo << "ai_#{type}_embeddings_2_1_search_bit" - end - - subject.up - - remaininig_idxs = - DB.query_single( - "SELECT indexname FROM pg_indexes WHERE indexname IN (:names)", - names: all_idx_names, - ) - - expect(remaininig_idxs).to contain_exactly(*expected_model_idxs) - # This method checks dimensions are correct. - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_def)).to eq(true) - end - end - - context "when there is an embedding definition with different dimenstions" do - fab!(:embedding_def) { Fabricate(:embedding_definition, id: 2, dimensions: 1536) } - fab!(:embedding_def_2) { Fabricate(:embedding_definition, id: 3, dimensions: 768) } - - it "updates the index to use the right dimensions" do - expected_model_idxs = - %w[topics posts document_fragments].reduce([]) do |memo, type| - memo << "ai_#{type}_embeddings_2_1_search_bit" - memo << "ai_#{type}_embeddings_3_1_search_bit" - end - - subject.up - - remaininig_idxs = - DB.query_single( - "SELECT indexname FROM pg_indexes WHERE indexname IN (:names)", - names: all_idx_names, - ) - - expect(remaininig_idxs).to contain_exactly(*expected_model_idxs) - # This method checks dimensions are correct. - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_def_2)).to eq(true) - end - end - - context "when there are indexes outside the pre-seeded range" do - before { %w[topics posts document_fragments].each { |type| connection.execute <<-SQL } } - CREATE INDEX IF NOT EXISTS ai_#{type}_embeddings_9_1_search_bit ON ai_#{type}_embeddings - USING hnsw ((binary_quantize(embeddings)::bit(556)) bit_hamming_ops) - WHERE model_id = 9 AND strategy_id = 1; - SQL - - let(:all_idx_names) do - %w[topics posts document_fragments].reduce([]) do |memo, type| - (1..8).each { |model_id| memo << "ai_#{type}_embeddings_#{model_id}_1_search_bit" } - - memo - end - end - - it "leaves them untouched" do - expected_model_idxs = - %w[topics posts document_fragments].reduce([]) do |memo, type| - memo << "ai_#{type}_embeddings_9_1_search_bit" - end - - subject.up - - other_idxs = - DB.query_single( - "SELECT indexname FROM pg_indexes WHERE indexname IN (:names)", - names: expected_model_idxs, - ) - - expect(other_idxs).to contain_exactly(*expected_model_idxs) - end - end - end -end diff --git a/spec/fabricators/ai_artifact_fabricator.rb b/spec/fabricators/ai_artifact_fabricator.rb deleted file mode 100644 index 44b01de1..00000000 --- a/spec/fabricators/ai_artifact_fabricator.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true -Fabricator(:ai_artifact) do - user - post - name { sequence(:name) { |i| "artifact_#{i}" } } - html { "<div>Test Content</div>" } - css { ".test { color: blue; }" } - js { "console.log('test');" } - metadata { { public: false } } -end - -Fabricator(:ai_artifact_key_value) do - ai_artifact - user - key { sequence(:key) { |i| "key_#{i}" } } - value { "value" } - public { false } -end - -Fabricator(:ai_artifact_version) do - ai_artifact - version_number { sequence(:version_number) { |i| i } } - html { "<div>Version Content</div>" } - css { ".version { color: red; }" } - js { "console.log('version');" } - change_description { "Test change" } -end diff --git a/spec/fabricators/ai_persona_fabricator.rb b/spec/fabricators/ai_persona_fabricator.rb deleted file mode 100644 index 4b3c3f02..00000000 --- a/spec/fabricators/ai_persona_fabricator.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -Fabricator(:ai_persona) do - name { sequence(:name) { |i| "persona_#{i}" } } - description "I am a test bot" - system_prompt "You are a test bot" -end diff --git a/spec/fabricators/ai_summary_fabricator.rb b/spec/fabricators/ai_summary_fabricator.rb deleted file mode 100644 index d596dbab..00000000 --- a/spec/fabricators/ai_summary_fabricator.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -Fabricator(:ai_summary) do - summarized_text "complete summary" - original_content_sha "123" - algorithm "test" - target { Fabricate(:topic) } - summary_type AiSummary.summary_types[:complete] - origin AiSummary.origins[:human] - highest_target_number 1 -end - -Fabricator(:topic_ai_gist, from: :ai_summary) do - summarized_text "gist" - summary_type AiSummary.summary_types[:gist] - origin AiSummary.origins[:system] -end diff --git a/spec/fabricators/ai_tool_fabricator.rb b/spec/fabricators/ai_tool_fabricator.rb deleted file mode 100644 index dc464fe7..00000000 --- a/spec/fabricators/ai_tool_fabricator.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -Fabricator(:ai_tool) do - name "github tool" - tool_name "github_tool" - description "This is a tool for GitHub" - summary "This is a tool for GitHub" - script "puts 'Hello, GitHub!'" - created_by_id 1 -end diff --git a/spec/fabricators/classification_result_fabricator.rb b/spec/fabricators/classification_result_fabricator.rb deleted file mode 100644 index 23e63c28..00000000 --- a/spec/fabricators/classification_result_fabricator.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -Fabricator(:classification_result) do - target { Fabricate(:post) } - classification_type "sentiment" -end - -Fabricator(:sentiment_classification, from: :classification_result) do - model_used "cardiffnlp/twitter-roberta-base-sentiment-latest" - classification { { negative: 0.72, neutral: 0.23, positive: 0.4 } } -end - -Fabricator(:emotion_classification, from: :classification_result) do - model_used "j-hartmann/emotion-english-distilroberta-base" - classification do - { sadness: 0.72, surprise: 0.23, fear: 0.4, anger: 0.87, joy: 0.22, disgust: 0.70 } - end -end diff --git a/spec/fabricators/embedding_definition_fabricator.rb b/spec/fabricators/embedding_definition_fabricator.rb deleted file mode 100644 index 5daee977..00000000 --- a/spec/fabricators/embedding_definition_fabricator.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -Fabricator(:embedding_definition) do - display_name "Multilingual E5 Large" - provider "hugging_face" - tokenizer_class "DiscourseAi::Tokenizer::MultilingualE5LargeTokenizer" - api_key "123" - url "https://test.com/embeddings" - provider_params nil - pg_function "<=>" - max_sequence_length 512 - dimensions 1024 -end - -Fabricator(:cloudflare_embedding_def, from: :embedding_definition) do - display_name "BGE Large EN" - provider "cloudflare" - pg_function "<#>" - tokenizer_class "DiscourseAi::Tokenizer::BgeLargeEnTokenizer" - provider_params nil -end - -Fabricator(:open_ai_embedding_def, from: :embedding_definition) do - display_name "ADA 002" - provider "open_ai" - url "https://api.openai.com/v1/embeddings" - tokenizer_class "DiscourseAi::Tokenizer::OpenAiTokenizer" - provider_params { { model_name: "text-embedding-ada-002" } } - max_sequence_length 8191 - dimensions 1536 -end - -Fabricator(:gemini_embedding_def, from: :embedding_definition) do - display_name "Gemini's embedding-001" - provider "google" - dimensions 768 - max_sequence_length 1536 - tokenizer_class "DiscourseAi::Tokenizer::OpenAiTokenizer" - url "https://generativelanguage.googleapis.com/v1beta/models/embedding-001:embedContent" -end diff --git a/spec/fabricators/inferred_concept_fabricator.rb b/spec/fabricators/inferred_concept_fabricator.rb deleted file mode 100644 index 4b1fdf80..00000000 --- a/spec/fabricators/inferred_concept_fabricator.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -Fabricator(:inferred_concept) { name { sequence(:name) { |i| "concept_#{i}" } } } diff --git a/spec/fabricators/llm_model_fabricator.rb b/spec/fabricators/llm_model_fabricator.rb deleted file mode 100644 index b2b76ae6..00000000 --- a/spec/fabricators/llm_model_fabricator.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -Fabricator(:llm_model) do - display_name "A good model" - name "gpt-4-turbo" - provider "open_ai" - tokenizer "DiscourseAi::Tokenizer::OpenAiTokenizer" - api_key "123" - url "https://api.openai.com/v1/chat/completions" - max_prompt_tokens 131_072 - input_cost 10 - cached_input_cost 2.5 - output_cost 40 -end - -Fabricator(:anthropic_model, from: :llm_model) do - display_name "Claude 3 Opus" - name "claude-3-opus" - max_prompt_tokens 200_000 - url "https://api.anthropic.com/v1/messages" - tokenizer "DiscourseAi::Tokenizer::AnthropicTokenizer" - provider "anthropic" -end - -Fabricator(:hf_model, from: :llm_model) do - display_name "Llama 3.1" - name "meta-llama/Meta-Llama-3.1-70B-Instruct" - max_prompt_tokens 64_000 - tokenizer "DiscourseAi::Tokenizer::Llama3Tokenizer" - url "https://test.dev/v1/chat/completions" - provider "hugging_face" -end - -Fabricator(:open_router_model, from: :llm_model) do - display_name "OpenRouter" - name "openrouter-1.0" - provider "open_router" - tokenizer "DiscourseAi::Tokenizer::OpenAiTokenizer" - max_prompt_tokens 64_000 - url "https://openrouter.ai/api/v1/chat/completions" -end - -Fabricator(:vllm_model, from: :llm_model) do - display_name "Llama 3.1 vLLM" - name "meta-llama/Meta-Llama-3.1-70B-Instruct" - max_prompt_tokens 64_000 - tokenizer "DiscourseAi::Tokenizer::Llama3Tokenizer" - url "https://test.dev/v1/chat/completions" - provider "vllm" -end - -Fabricator(:fake_model, from: :llm_model) do - display_name "Fake model" - name "fake" - provider "fake" - tokenizer "DiscourseAi::Tokenizer::OpenAiTokenizer" - max_prompt_tokens 32_000 - api_key "fake" - url "https://fake.test/" -end - -Fabricator(:gemini_model, from: :llm_model) do - display_name "Gemini" - name "gemini-1.5-pro" - provider "google" - tokenizer "DiscourseAi::Tokenizer::OpenAiTokenizer" - max_prompt_tokens 800_000 - url "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro-latest" -end - -Fabricator(:bedrock_model, from: :anthropic_model) do - url "" - provider "aws_bedrock" - api_key "asd-asd-asd" - name "claude-3-sonnet" - provider_params { { region: "us-east-1", access_key_id: "123456" } } -end - -Fabricator(:nova_model, from: :llm_model) do - display_name "Amazon Nova pro" - name "amazon.nova-pro-v1:0" - provider "aws_bedrock" - tokenizer "DiscourseAi::Tokenizer::OpenAiTokenizer" - max_prompt_tokens 300_000 - api_key "fake" - url "" - provider_params { { region: "us-east-1", access_key_id: "123456" } } -end - -Fabricator(:cohere_model, from: :llm_model) do - display_name "Cohere Command R+" - name "command-r-plus" - provider "cohere" - api_key "ABC" - url "https://api.cohere.ai/v1/chat" -end - -Fabricator(:samba_nova_model, from: :llm_model) do - display_name "Samba Nova" - name "samba-nova" - provider "samba_nova" - api_key "ABC" - url "https://api.sambanova.ai/v1/chat/completions" -end - -Fabricator(:ollama_model, from: :llm_model) do - display_name "Ollama llama 3.1" - name "llama-3.1" - provider "ollama" - api_key "ABC" - tokenizer "DiscourseAi::Tokenizer::Llama3Tokenizer" - url "http://api.ollama.ai/api/chat" - provider_params { { enable_native_tool: true } } -end - -Fabricator(:mistral_model, from: :llm_model) do - display_name "Mistral Large" - name "mistral-large-latest" - provider "mistral" - api_key "ABC" - tokenizer "DiscourseAi::Tokenizer::MistralTokenizer" - url "https://api.mistral.ai/v1/chat/completions" - provider_params { { disable_native_tools: false } } -end - -Fabricator(:seeded_model, from: :llm_model) do - id "-2" - display_name "CDCK Hosted Model" - name "cdck-hosted" - provider "fake" - api_key "DSC" - tokenizer "DiscourseAi::Tokenizer::OpenAiTokenizer" - url "https://cdck.test/" - enabled_chat_bot true -end diff --git a/spec/fabricators/llm_quota_fabricator.rb b/spec/fabricators/llm_quota_fabricator.rb deleted file mode 100644 index 9aa97560..00000000 --- a/spec/fabricators/llm_quota_fabricator.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -Fabricator(:llm_quota) do - group - llm_model - max_tokens { 1000 } - max_usages { 10 } - duration_seconds { 1.day.to_i } -end diff --git a/spec/fabricators/llm_quota_usage_fabricator.rb b/spec/fabricators/llm_quota_usage_fabricator.rb deleted file mode 100644 index 439cf739..00000000 --- a/spec/fabricators/llm_quota_usage_fabricator.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -Fabricator(:llm_quota_usage) do - user - llm_quota - input_tokens_used { 0 } - output_tokens_used { 0 } - usages { 0 } - started_at { Time.current } - reset_at { Time.current + 1.day } -end diff --git a/spec/fabricators/rag_document_fragment_fabricator.rb b/spec/fabricators/rag_document_fragment_fabricator.rb deleted file mode 100644 index d70d2f9d..00000000 --- a/spec/fabricators/rag_document_fragment_fabricator.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -Fabricator(:rag_document_fragment) do - fragment { sequence(:fragment) { |n| "Document fragment #{n}" } } - upload - fragment_number { sequence(:fragment_number) { |n| n + 1 } } -end diff --git a/spec/fixtures/bot/openai_artifact_call.txt b/spec/fixtures/bot/openai_artifact_call.txt deleted file mode 100644 index ce712111..00000000 --- a/spec/fixtures/bot/openai_artifact_call.txt +++ /dev/null @@ -1,299 +0,0 @@ -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_yH3ixdEz4wvSuK8ei3gNYwk3","type":"function","function":{"name":"create_artifact","arguments":""}}],"refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"name"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Five"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Lines"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"HTML"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"CSS"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"JS"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Hello"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"World"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\",\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"html"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_body"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"<h"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":">Hello"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"</"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"h"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":">\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"<p"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":">"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"World"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"!</"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"p"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":">\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"<button"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" onclick"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"=\\\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"show"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Message"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"()"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\\">"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"G"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"reet"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"</"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"button"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":">\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"<div"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" id"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"=\\\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"message"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\\"></"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"div"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":">\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"<hr"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":">"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\",\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"css"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"body"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" {"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" margin"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"0"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":";"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" }\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"nh"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" {"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" color"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" blue"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":";"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" }\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"np"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" {"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" font"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"-size"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"20"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"px"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":";"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" }\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"button"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" {"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" padding"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"10"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"px"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":";"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" }\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"hr"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" {"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" border"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"px"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" solid"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" #"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ccc"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":";"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" }"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\",\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"js"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"function"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" show"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Message"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"()"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" {\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" var"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" message"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Div"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" ="}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" document"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".get"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Element"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"By"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Id"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"('"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"message"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"');"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" message"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Div"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".text"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Content"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" ="}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" '"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Hello"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":","}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" World"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"!"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"';"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"}\\"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null} - -data: {"id":"chatcmpl-ATimVYagKnCWQ0VXY0Hn2SDjRuN6B","object":"chat.completion.chunk","created":1731647015,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_45cf54deae","choices":[],"usage":{"prompt_tokens":735,"completion_tokens":156,"total_tokens":891,"prompt_tokens_details":{"cached_tokens":33,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} - -data: [DONE] - - diff --git a/spec/fixtures/embeddings/embedding.txt b/spec/fixtures/embeddings/embedding.txt deleted file mode 100644 index b9168ae9..00000000 --- a/spec/fixtures/embeddings/embedding.txt +++ /dev/null @@ -1 +0,0 @@ -[0.04243462532758713,0.04052888974547386,-0.04421206936240196,-0.022003429010510445,-0.004844799172133207,-0.006922865752130747,0.02839154377579689,-0.01876010186970234,-0.049603171646595,0.0013633513590320945,0.054700348526239395,-0.006008867174386978,0.0001746713533066213,0.014254634268581867,-0.0005745103117078543,-0.0753706619143486,0.04804191365838051,-0.010533238761126995,0.02913311868906021,0.029538920149207115,-0.012650452554225922,0.018038887530565262,-0.0008972134091891348,0.009404314681887627,-0.02414906583726406,-0.03138790652155876,-0.03082532435655594,-0.0018285108963027596,-0.060906074941158295,-0.11439098417758942,0.061947520822286606,0.030666323378682137,0.017787793651223183,-0.041161734610795975,1.6135549003593042e-06,0.004046124871820211,-0.01945754513144493,-0.008100807666778564,0.023051513358950615,-0.001117440522648394,0.06992015242576599,-0.05605917423963547,-0.007281680125743151,0.02890240214765072,0.012937389314174652,-0.02954990603029728,0.015063030645251274,0.08909539878368378,-0.013112880289554596,0.048541467636823654,-0.007999212481081486,-0.04678202047944069,-0.017453458160161972,-0.06211870163679123,0.042611781507730484,0.0259271077811718,-0.01948642171919346,0.043407414108514786,0.04912532866001129,0.017264019697904587,-0.030157916247844696,-0.024087289348244667,-0.028151409700512886,-0.02284383960068226,0.0462375283241272,0.04589765891432762,0.05584162101149559,-0.04772880673408508,-0.009235077537596226,0.027659239247441292,0.04838871210813522,-0.003665389958769083,0.03855258971452713,0.054029010236263275,-0.02120707742869854,-0.03296450525522232,-0.07400854676961899,0.03554471209645271,0.012645337730646133,-0.03985796123743057,0.05106697976589203,0.009322862140834332,0.013169869780540466,0.0404234379529953,0.06577713042497635,0.023686740547418594,-0.007108944468200207,-0.026551678776741028,-0.036731988191604614,-0.05801860988140106,0.07095325738191605,-0.07009286433458328,-0.033584222197532654,0.02159971557557583,0.014910189434885979,-0.03402736783027649,0.02977721579372883,-0.024266956374049187,0.005024763289839029,-0.04889572411775589,0.013436459936201572,-0.01973094418644905,-0.007587197702378035,0.0070345052517950535,0.025946469977498055,-0.022228196263313293,0.027047745883464813,-0.03185977041721344,-0.026152413338422775,0.07336126267910004,-0.024811603128910065,0.0007995390333235264,-0.030174896121025085,0.029422475025057793,0.02582784928381443,-0.017767321318387985,0.03522718325257301,0.018522055819630623,-0.029678015038371086,-0.00944459717720747,-0.008015641011297703,0.0036377711221575737,-0.030483251437544823,-0.003844456048682332,0.023506812751293182,0.0009358337847515941,-0.04048951342701912,0.02910909615457058,0.04450681433081627,0.04193754866719246,-0.00040311008342541754,0.016226595267653465,-0.0039861127734184265,0.04250023886561394,0.009863423183560371,-0.023240908980369568,-0.0212419256567955,0.0041670785285532475,-0.038493480533361435,-0.024678971618413925,-0.013693751767277718,0.054596561938524246,-0.031480103731155396,-0.053646497428417206,-0.022942569106817245,-0.004820613656193018,0.04021015018224716,0.047066934406757355,-0.024262387305498123,0.05222921073436737,-0.018980681896209717,0.0017223964678123593,-0.046582575887441635,0.034885916858911514,0.07407543808221817,0.022425837814807892,0.0501948744058609,0.018618330359458923,-0.048334963619709015,0.055843524634838104,0.02787795476615429,0.04972230643033981,0.018554141744971275,-0.06295017153024673,-0.02288006991147995,0.027160825207829475,0.06576468050479889,0.004971513990312815,-0.024689659476280212,0.014432616531848907,-0.033505626022815704,-0.008328924886882305,-0.04383063688874245,0.10610033571720123,0.032539308071136475,0.07491374760866165,0.021760588511824608,0.06307519972324371,-0.05007459595799446,-0.012742679566144943,-0.03355658799409866,-0.028821846470236778,0.012304635718464851,0.017320752143859863,-0.0024572305846959352,-0.011460098437964916,-0.051598913967609406,-0.08589107543230057,-0.052754100412130356,-0.01863238587975502,-0.045892003923654556,0.0031949724070727825,-0.011224047280848026,-0.026323318481445312,-0.013344930484890938,0.0661853551864624,-0.004145580343902111,-0.021077025681734085,-0.02910938300192356,-0.003964030183851719,-0.020560482516884804,-0.013315360061824322,0.10082336515188217,0.019182242453098297,-0.018292753025889397,0.021930959075689316,-0.006332505960017443,-0.02044707164168358,0.06978331506252289,0.028775865212082863,0.07434634119272232,-0.015817152336239815,-0.05904664844274521,-0.0004482355434447527,0.031191041693091393,-0.05641834810376167,0.03991707041859627,-0.0027933232486248016,-0.0010528061538934708,0.050011057406663895,0.019661054015159607,0.048614248633384705,-0.03330226615071297,0.013398805633187294,0.07869576662778854,0.015246203169226646,0.006255229469388723,-0.01368493027985096,-0.015446044504642487,-0.0031753869261592627,0.006126131862401962,-0.006846912205219269,0.005686603952199221,0.008205730468034744,-0.04692327231168747,-0.0026113525964319706,0.03259606659412384,0.026416363194584846,-0.014561568386852741,-0.10521991550922394,0.022616812959313393,-0.006916450336575508,-0.003462881548330188,0.023143386468291283,0.0913265198469162,-0.04385364055633545,-0.03888934850692749,-0.003976820036768913,0.017746202647686005,0.011851535178720951,-0.035226158797740936,0.017662769183516502,-0.013187313452363014,0.019017690792679787,-0.0122595289722085,0.032169442623853683,-0.058525800704956055,0.017246566712856293,-0.0037133092992007732,-0.021517211571335793,0.08164826035499573,-0.03902203217148781,0.02644680067896843,0.040329400449991226,0.0016956302570179105,-0.003388921497389674,0.005922307260334492,-0.06725191324949265,0.008442799560725689,-0.026716893538832664,-0.020523274317383766,0.024183889850974083,0.01112766657024622,-0.01428288035094738,0.013506755232810974,-0.002534078201279044,-0.009114297106862068,-0.015208842232823372,0.009578749537467957,0.003927712794393301,0.006001129746437073,0.05649565905332565,-0.03832196816802025,0.0118902288377285,-0.012274972163140774,0.04189355671405792,0.02789502777159214,0.004132899455726147,-0.03045535460114479,-0.0025840962771326303,-0.0022449190728366375,-0.030739402398467064,0.031944748014211655,0.004202446434646845,-0.011785048991441727,-0.008584163151681423,0.010890045203268528,-0.014684873633086681,0.010178463533520699,0.06132793799042702,0.03458993881940842,-0.0030932044610381126,0.04541677236557007,-0.014607960358262062,-0.01741594634950161,-0.05395742878317833,0.01846872642636299,0.003908093087375164,0.04405622184276581,-0.05807092413306236,0.02897859551012516,-0.00575090991333127,0.0004049566341564059,0.002484238240867853,-0.04924604296684265,0.038471564650535583,-0.0761745274066925,-0.05612573027610779,0.021203787997364998,0.051370054483413696,-0.03726191073656082,-0.01299006026238203,-0.017698200419545174,0.06560660153627396,0.020712800323963165,-0.058766987174749374,-0.04088710620999336,-0.018412798643112183,0.011763897724449635,-0.08678927272558212,0.025378961116075516,0.010957960039377213,-0.03186371177434921,-0.0248881783336401,-0.015695368871092796,-0.014042080380022526,-0.011396333575248718,-0.029504578560590744,-0.041495706886053085,-0.02175172232091427,-0.001259099692106247,-0.029206540435552597,-0.03829728811979294,-0.012222552672028542,0.01076850388199091,0.017942223697900772,-0.008067481219768524,0.04724811762571335,-0.0009130592807196081,0.019615858793258667,-0.02062820829451084,0.029505698010325432,-0.01785159856081009,-0.021811818704009056,0.005541566759347916,0.048550985753536224,-0.005532615818083286,0.018409406766295433,-0.1135888621211052,0.005203129258006811,0.007288631517440081,-0.013667332008481026,0.018191196024417877,0.029409246519207954,-0.03493089601397514,0.04489494860172272,0.0430302657186985,-0.04109568148851395,-0.03581022098660469,-0.03384891524910927,-0.01542040053755045,-0.017123982310295105,0.04033771529793739,0.08237975090742111,0.018430456519126892,0.017952818423509598,-0.002727270359173417,-0.009234322234988213,-0.07217827439308167,0.005972042679786682,0.03565335273742676,0.009920922107994556,0.09037874639034271,-0.0033067602198570967,-0.014181774109601974,0.01580287702381611,0.0032972567714750767,-0.044889241456985474,0.04570966213941574,-0.0126307662576437,0.0012942332541570067,-0.020719027146697044,0.01722092553973198,-0.034364473074674606,-0.05495787039399147,0.016455693170428276,-0.0425790511071682,0.018801890313625336,-0.004771193955093622,0.016840685158967972,0.01875404827296734,-0.0577424094080925,0.004995260387659073,-0.05124162137508392,0.008025240153074265,0.036425184458494186,0.04310254752635956,0.04722069203853607,-0.03350804001092911,0.02470981702208519,-0.030357152223587036,-0.05656179040670395,-0.00027739349752664566,0.00417818408459425,-0.0019614570774137974,-0.04717211052775383,0.0018856748938560486,-0.008592973463237286,0.01606384664773941,0.00036288925912231207,-0.014128617011010647,0.04727170616388321,0.0039599621668457985,-0.022990575060248375,-0.027524959295988083,-0.03055763430893421,0.03185751661658287,0.04808570072054863,0.03076675720512867,0.007127637974917889,-0.024111023172736168,0.0401727594435215,-0.04077097773551941,-0.03401493281126022,0.0022631569299846888,0.011889907531440258,-0.030453555285930634,-0.06679023802280426,-0.03548044711351395,-0.07280927896499634,-0.015783313661813736,0.009307941421866417,0.06562621146440506,0.042230408638715744,0.002490038052201271,0.01156869437545538,0.00848419964313507,-0.0844128429889679,-0.006674913223832846,0.04956210404634476,-0.00909928884357214,-0.07594230026006699,-0.035542283207178116,-0.008571446873247623,0.023849060758948326,-0.04960036277770996,0.01369475107640028,-0.016830958425998688,0.0055944714695215225,-0.022306229919195175,-0.01998482644557953,0.016610750928521156,-0.037722934037446976,-0.0026923702098429203,-0.04902104660868645,0.032266270369291306,-0.009657110087573528,0.018828513100743294,0.014384991489350796,-0.02155076339840889,0.02121565490961075,0.0117281349375844,-0.019374320283532143,0.013489572331309319,0.05799110606312752,-0.026703916490077972,-0.07911781221628189,-0.03191741183400154,0.014480691403150558,-0.03900337964296341,-0.0028972725849598646,0.06299537420272827,-0.022537481039762497,-0.04221101105213165,0.0029901983216404915,-0.030557870864868164,0.04023182392120361,-0.018693121150135994,0.035419829189777374,-0.03989320620894432,0.005803779698908329,0.01011386327445507,-0.03292561322450638,0.015612144954502583,-0.022460049018263817,0.009273167699575424,-0.026777101680636406,-0.008563129231333733,-0.026478594169020653,0.01941636949777603,-0.047846719622612,0.04790546000003815,0.003327126381918788,0.03864036127924919,-0.040581364184617996,-0.0357230119407177,-0.02759632281959057,0.03902235999703407,0.004351404495537281,-0.01684054732322693,0.008489971980452538,0.006280013360083103,-0.002362377243116498,-0.0600287988781929,0.008911814540624619,0.08594183623790741,-0.03564982861280441,-0.0726194679737091,-0.029546387493610382,-0.004653627518564463,0.009619813412427902,-0.02269834838807583,0.0481029637157917,-0.041859522461891174,-0.07778097689151764,0.05442032590508461,0.01279397215694189,0.006662658881396055,-0.052069395780563354,0.025886455550789833,-0.023964358493685722,-0.06786635518074036,-0.023886743932962418,0.027276312932372093,0.012271332554519176,0.10062718391418457,0.05958443507552147,0.002318332204595208,0.028790317475795746,0.038131438195705414,0.004359631799161434,0.021099137142300606,0.047862228006124496,-0.016529781743884087,0.0745123028755188,0.030879484489560127,0.06069055199623108,0.030661433935165405,-0.011549228802323341,-0.002443350851535797,-0.017710261046886444,-0.009367888793349266,-0.03765418007969856,0.0010898427572101355,0.01620819792151451,-5.0129243117564464e-33,-0.009640307165682316,0.00087989546591416,-0.00928567349910736,0.09380844980478287,-0.028309084475040436,-0.005309718195348978,-0.01538156159222126,0.07278260588645935,-0.008638891391456127,0.027243157848715782,-0.01760569028556347,0.04673650860786438,0.008144351653754711,0.0012032748199999332,0.03526134416460991,0.0032172310166060925,0.05649680271744728,-0.00776107469573617,0.021631112322211266,-0.0057723126374185085,-0.0843375101685524,-0.02060241810977459,0.04195826128125191,-0.08616340905427933,0.024213628843426704,0.00020206913177389652,-0.03634003549814224,-0.017440084367990494,0.10868581384420395,-0.022558121010661125,0.0037188082933425903,0.0006387356552295387,0.019524194300174713,-0.04796990379691124,-0.0008834723266772926,0.021940935403108597,0.0024039745330810547,-0.0461544506251812,0.017232051119208336,-0.015803003683686256,-0.021417854353785515,-0.0990128219127655,-0.03378203511238098,0.01621081493794918,-0.0003299622912891209,0.021240534260869026,0.026942312717437744,-0.020670520141720772,-0.0037585818208754063,0.0331760048866272,-0.050560206174850464,0.0022465703077614307,-0.01956053450703621,0.01428166776895523,0.014047535136342049,-0.003448419040068984,-0.019317813217639923,-0.017492180690169334,-0.01679806597530842,0.03915645182132721,0.02318747527897358,0.08144408464431763,0.010069039650261402,0.000573420780710876,-0.07947671413421631,0.034329138696193695,-0.06870871037244797,0.06020539626479149,0.012417258694767952,0.029848141595721245,-0.006599405314773321,0.08396591991186142,0.014299587346613407,0.0025849021039903164,-0.027586810290813446,-0.05588001012802124,0.011065784841775894,0.01808340661227703,0.03223351761698723,-0.013396292924880981,-0.012974814511835575,-0.00700297299772501,-0.01876917853951454,-0.03127433359622955,-0.014449989423155785,-0.016484234482049942,-0.02805330604314804,-0.01317337341606617,0.006451652850955725,0.018404122442007065,0.009499366395175457,0.07043331861495972,-0.044716160744428635,-0.01930193230509758,0.01626572757959366,0.0005197832360863686,-0.03506121784448624,0.019674647599458694,0.017579127103090286,0.031061675399541855,-0.0389145202934742,-0.02617526985704899,0.007924065925180912,0.10315810889005661,0.004901312291622162,0.01741728000342846,0.00830888468772173,-0.0030416734516620636,-0.10309452563524246,-0.015371519140899181,0.052728258073329926,-0.03613533452153206,0.03384026885032654,0.04974118620157242,-0.04324078932404518,0.018245140090584755,0.026079831644892693,0.023392947390675545,0.0019466059748083353,-0.026244359090924263,0.015367992222309113,-0.037361372262239456,0.0020822107326239347,0.020749090239405632,0.004831152968108654,0.019612174481153488,-0.02410382591187954,-0.019045215100049973,0.08687453716993332,-0.027951089665293694,-0.028818292543292046,0.0011331926798447967,2.202135931383964e-07,-0.02940104901790619,0.03923550248146057,-0.01213000901043415,0.02520550973713398,0.04429462179541588,-0.03311247006058693,-0.035315632820129395,0.041586559265851974,-0.03394998237490654,0.03174516186118126,0.003323053242638707,-0.006339453626424074,0.01987832970917225,-0.03396957367658615,-0.10340868681669235,-0.041069962084293365,-0.055795494467020035,0.005858666263520718,-0.07069703191518784,-0.023767197504639626,0.04440637305378914,0.02730334922671318,0.01567237451672554,0.021953849121928215,-0.044462401419878006,0.0057586790062487125,-0.011733911000192165,-0.006293540820479393,-0.001240032259374857,0.0329342782497406,0.06380878388881683,0.08386953920125961,-0.006812266539782286,-0.05886104702949524,-0.008270432241261005,-0.10268088430166245,0.022508276626467705,0.0023926012217998505,0.04022349417209625,0.04956118389964104,0.011926800012588501,0.06860803812742233,-0.03877260535955429,-0.059650130569934845,0.03730024769902229,0.023558031767606735,0.01990872621536255,-0.03984316810965538,-0.01602226309478283,0.020894154906272888,0.04681435227394104,-0.022832773625850677,0.00934226531535387,0.022772863507270813,0.009725779294967651,0.018796870484948158,0.005381118971854448,-0.003425160888582468,0.06812065839767456,0.01685892604291439,0.012802419252693653,-0.03323640674352646,-0.012599806301295757,0.01301077101379633,0.03590386360883713,-0.029968200251460075,0.016591966152191162,9.625985485639475e-35,0.02049880102276802,-0.01748824678361416,-0.021914057433605194,-0.055099453777074814,-0.03463204577565193,-0.015192211605608463,-0.022814888507127762,-0.022820571437478065,-0.03265628218650818,0.010444738902151585,-0.016098372638225555] \ No newline at end of file diff --git a/spec/fixtures/images/100x100.jpg b/spec/fixtures/images/100x100.jpg deleted file mode 100644 index 2cd37c8bdcb07d35f07dedf0a3436faa2bf8ae4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 415 zcmchS%?-jZ3`XsyL~)X~b_=Z2L#0Y^WrYsHB+SBrA^5ql05b%qC(4ZrFH#gg<%^wk zO;6zcxElcx0ChonzyPMm<lQKxEi=yAl_lprIU*mM=tCWdDyiy*S=h(6Yq`@i=SMIn zP3bhqfr8o}NoNoT6HEpL3PB^7F0h$Lg-=NJnsxRiYPYB?t(|Ynm(_pYAsW;^Mxck& O>o6Rb!9Q1hy1fB!h9GqS diff --git a/spec/fixtures/images/1x1.gif b/spec/fixtures/images/1x1.gif deleted file mode 100644 index 95a5eff67588fe0d662e258e81f4b30c97669125..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43 scmZ?wbhEHbWMp7u_`tyM|Nnmm1_s5SEI^WhK?g*DWEhy3To@Uw0oMfwXaE2J diff --git a/spec/fixtures/images/1x1.jpg b/spec/fixtures/images/1x1.jpg deleted file mode 100644 index f083de6bde463749f56437c86bd127a70b1eba96..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 160 zcmZ9Gu@1sO3_~4SPD$?C=34%T0jUzqd@uta0d#;r36~L`CCl<)*&qIaUDopoB7mCU z8&2qUL3r2bQy*|j`y{L?ETUy9n#U|M)M1>=s%e>**_M{gZ^#^bL=}F=A(nrTXzjm+ Mzrb<ji0j**UstXaGynhq diff --git a/spec/fixtures/images/1x1.webp b/spec/fixtures/images/1x1.webp deleted file mode 100644 index c78718cda3c6ea710aaf14e083d87b10d62bc6bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44 ycmWIYbaPW-U|<M$bqWXzuuzZyvJDs+<}zwAGB7eQF_@?>VPGy``1gAX0|Nl9^$3ap diff --git a/spec/fixtures/images/An image of discobot in action.png b/spec/fixtures/images/An image of discobot in action.png deleted file mode 100644 index e3afae9bd5f63244613956878ad8a771f05da7af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14859 zcmeHuRa6|&)@I|<xHK9(XyY0nxI^%e#v6ADZowOO2_7s2hsGheLvRbhEx{oWf<u^2 z?tj;sx0%Ozm|m;8PMz9kpS^c|N586QO?5?FY)WhZ0D!BkB&Q7kAddh5NJ*H;h#c_; zBxV5M#jLWNw64$c@lvmfy)<>t*1Gb^A#tPaq8=tP{kGIJ5S7!g5nINVkkc;)BrOrb z+WIxUbruG^6mgg4O(0msTlKfwzSl$PANLA;*rP)RJUquB1V}*u08<t)Ab0>1`0v6B zai-(#Lk19l08$i)vr-%!00>6|XhtB;M2uhnzzhkHM*n|V<R*-u&%VikE5r9gWIP0J zQrpsAxxgMyfCMnBdp!=5VNH|Z;6qeAaN^2vY6u3^*4E<V;|mK5>%Y&-&(F`x6BJh0 z&}aa+cXc_ptmLV)grAK%*<sWF_Uk;Iv=xXEn>%PU<jI*Z*B;t=+X8JfQUGXQto1W& zuy*C0c-?PrZ}0DyX%C&Bpa1yr<Kn{oyqN@p{NlFbwX45BmG}Dkx&hkHpFeroG&D4- zL$_SXpK1fUpKfl*k#3UB>P%`c#GfD=8yoq#xw==+XlR5e<NZ4S9PwZJb<Wt%+c$mb zvTnHj^()2J1T!|sj4x|-ZLP_my0Wqovu6qzn^+su3r(`pFM;L|BC-vt^}0S8JWfn5 zHGU!tfBT>+;AC#T;*k2Oq>Dh<w5L7aEa{7hV`q7!4<|{?w1b6THLMZiC1x{*sT3MF z1C4IkG>8}{mWBjB>%luCJ&%b?m7r+%mUpB|lz+@<clb&FYuM9K#g<U8dx0JwO=8<n z1&N{(7^E(mXHCfjLX{?PU<Eaxbgi854UJ2L1oX);AFB?9M63Y(%o3Du++@w`Ttj>5 zqS2%Y2K-_MtF8!R2d!xUC7FA~_IO*VcmjK8F}OEO58-v|z7`Wm=rN2OgvOAlK})_{ z2jq-^61qA%%uoo*dnvG7$;jS?Gm1_PodTW&?L5*D{NCK#<|2CMJi^`oeko!4Y~t$> zqsP_tl{n3U0MFsqB_`$B`w%H9t+W!i*aMGc=N5;iWhf6E1iZ(Il>I2ZU{5+%G#j3E z5~<v2Qmb7qkTVh3ov4~K<|+CN9H~`2vyW$9$g0w5;?iW`w|0mZn*t#~l_nR*snbkk z<t^jE^G9k$TCRM|NWvmVktpf!zdkRXVqr1W2nv{by3;S)$9>#!InXdo*ofF@Tkr4^ z70OHC#Ch9hRFUS>2|AGH%Gja2zL#T3Sg;lNqBc^WJ8B6YDAHu*A^zcZn_Ud`VI?sa zjvC%sa{S7$#R`f|RtjIEOIMk=AUdWwWl2`zA=H1LI?80!n*d2uDRC7USKCA&2^}LA zqWcY(&{2W~Gn7tG3+%diz+G~7|LCU@;?c`lBw2m?P)@vssvT2|Ppc6`<`6XbWc&+Q z)675lWcrK4YjU?Knhs|tD5QjSISgAybzbM?uD!g5OyUcmJHbmfrbQ{45#F}%NwRoI z$<%I`N-(9f%|cZAjCi4#g$QfH`jUf+Orhsl2mR{8mR=*xj)kTjk`eIRZ#W@654*k_ zYa6FM=kG-V9Nby;f-*}EhcjwT`Z=8^C)R+lk0w3*;teAxRcbaXZd%oPOC;Lmy1_KS z<K;Rsn=UEtyT>lg-U*)XKZxdfyi9BLp`SfOPl7;}gQj2-NlmtNmD~$xs=Yo8S)L4m zOEN?7CUL!nyMZQs4$J9km*>vmal;<Bo}Q%3@3s@$fm-AB`ZhD%xKookN`aQE?$-Vu zojuQvn-lyEa@S0ZNqeuS&Ua5+TeGv5OBDNP**xNFRB+sK!8qO$1^1W&@!kL|I?xTd z82Y!bz;qw+$pqy4!0({cTOBaSVi<rG!wd@C@8<gU@n0j!&;fsZO&-D9b=ps*8{S`d z>#GFli@2Mb_^?B~@#I!c=+J5AU^BnGr5Hc6cv2{C^(eXF)rA`RYkzddf&UE+n2VuI zvk188!jL?2um{R@*FZzHY*=z@Lx3hU8s@a(7>ek&S_-&*#(%h}4c3E8nu|y=U4d-x zu((07eQ3jex=CuxT6^YFIg_Sf0-4R0of=y|Pn4&8Vs&i)VA6vRs6^4>h^9uzl>{k3 zW~i3=IXA$^?ayCGqLGmy+4R+kUX2TiPOi-y8sD^z84)tfMMmzjUcG7zk)kH@Qri}9 zl`o!wvbVN1xk?M01M1EKSe7jYQEvlGxcXa<n>AT8KfRa)h{rmYXcKZ>`mHs@6vr_< zS7<4Ed#zA9cdly*54?|(uht{xifZDC{qPbsCoNkT3t>(=WW;&GtG(I~c3j&L)U)0; ztn~VW*+agNllk1dIcl435h-DDD4ZSB{CPUWLD`lK({|~1lB*VDHl!%NoGqG4B}xI6 z6HB;PilE7`Y^DGjqK8EQEpaP{r;uLpiQ#0V9Zjy}U%<F+rm~)S*?b$tyMJ)2pFEwM z%=&@fZL@Lho8R(t!nob&@vx`_i(j0$QRs?kvKWVJF`dmv;Q0hzu88Dm-Ei%n-q%(X z49k7jaxp`P3zcC6=izqL_)est`>PXRwrm_*|Fbyt*3X1Ny*g~s?TU<^2_x>-lewdd zx2@1#<Vl}W5)KZIFoJm@2FXEc$LU;);e@8|bt8|7vTcKO8#yWvO!@&*2Jp?%hFxq` zMd-Hl)KN|Uozzc8tD*Bm8*>--QcWeH^G0!YicB83sVf-?4h`NigRV-SQ!>45#M#@^ z)i!y4L)Y&Z^qGQ9yT{+^imUrV86~5(rZ!tP0;zrwl7i~82ikvjIZPBX5paLk)zw9% zXRU+a<WfsxR_N)&28ZIQs$byZ;u5yl(F>=;31p%;<RBv7utC?a3)nI9$zOWq<{W=} z?akcc8`{_Z!IHQp_K{_d{6#XI6T4%<8@TBiBg?p}F;9xh{-CjmVYo+P4l`?C*DJg+ zx^NEmaC&71{0run396JlSq->A*!8+UH|F_4X}$qRlv)V`8hB{A$svp&Ap13hK$<Y) zWAe4p$gAhzaDr_W0?_Htz>3wHMS}9z(|<BaP}v$}ZDup+-ALF8Py<@laVk+!N=Rs? z8KPtL;+o%cY4RvH1nHd&x`Z^d0w>z<{$L)c$@3V>nNw}k>&~BD5P&+I_8!M^g@8}M zSs0d#W)JJlqs?`A5lcNbkf_crQeXOiB9KA`dZ67X;cR;`8&$FI^@^;NomEoPf25jS zlSY{znUnmKjt_{>Y^L8$alJqEQsiZIbp*~Stl4(N7I+A1+3*tcW6*9S=eLe)l2odR zA}u(Jfki&37{yDg2aJVbNMjZWf?3RVV2JMCsNM(ey!N@OFVs7y-TeG}ss_kS2Mb6a z&gyHnRq(h4gLyl+<<23!tw7dKGJLI>FXH;pcXl7iFBCdK>@pDi=TQsgyQx{akJZ-p zM+(na@kV*RIpV@+sh&#VK3}e!qvUqkci2+NF@J$T>r{2_d+JrB`ii`dDng-RVFY9E zIU9C$U%zg90SPEjOrYMHPsFh1NiyeU;IHXOAOoG#r}3~x(4o$KM893v?UTWzS22XN zx(!z8H!!>+xWkJ=p_v|&;x+nGu{7YFYF&oS(E0P3==_O>%^n6kDNdvI?$leU*IuAF zD<!g7#Of*o4QTh}%s%Ral5Du1di$q$?%C+{(-dE+)#sYAfz}Kn_vdP^t$cPA>v_F# z`--&h(iU1>_ooX-7PDwa%_5ls#Z2}J-V@xmi2D7uou-bEZ@>mpQYR11RlKg;X5@#h z3UGm2%dzOg_iy?_iTi%w-Yl3F3PP=Nx5tAwzj}1VtXdRZX5nEj>6OJO!AbLr0IbHV z9<o0>hbOfjpavf&Qp+y)ra|4$Bty1HYB@Wm(#qq#*0dw)=xis4X@eZe%qsX3se#(n z40~@^8wn-#lL@1uM|@biTjyT0rYj}D#^RxFg0^`5WgTgfn-qreOlf-~bv<O;O=!g& zgkgi9Ugh9mx7r0*V73%6ESzVoV$;h-4r0YJq;q`mJ7f!zxp#b(P38zx&C=pTpsh6% zNgA?QSujGl@qE`bHJu^cbfIKLukoxJge+P)_3`c7&l6>YyaURsHC*ZuEITLB^0A=g znmp5A%<ehT?$%QuCU7trA{t&Wh^_k_aiTnh)pe+zjtt*OJCjM{4k)VMupKj=yQ+Pr zdTT~VC@SaEwivf(PZBuSdY!6K?hD=Ip${i`rUPl^&%6+Eld)*nznG&6Iv-)gYS2pu z@uikf-?j>#zdn=U#EPXcuFbpr;-Ji*!u~62N+yA698_Zzw$#UMc%2e;CD;B(UiIq7 zk2TUh!^=Z@Vd~FYaZc)|RT6Qn%dX~Q++KUScm_zunGaqt9|xN)VKhs<BwS({%o$!7 zS{JI#+O<ss8OTFGNcW@!E3~OdFl2R}Xd0Bp;p)%~2BR~AO{y|e^WkTnv1v;qku;wR zuP=}76O|ag9G@IE-aK4P^;<nD$%o9!8&2fpwr9+PR+G^f&JXk`G1`K#g%!j{V}!Hr zOV2F7e{+sefx0qc66PPUK75kER{Mu}WrzrmV}x;1?$;{m{9nK0K2k)l4H!0A=aFm3 z!gz9GlFc>8NP)+MwcI8~QE;h!DD&fRw>Okf9w4uvalqK<O+{k^Fw;*adKu)T_>kki zJ6;!Cz!XZrBrNw?t5GO-g$_@aVSSzgBVX~WU(c@}V>^T<UCX`NM1ioIgGsRRH!%uR zrrl~9%&HM8W+1QuCXFN<|CC0?>-H7_AVo4zu8s3pm}Gh<yvMjVw2tykjqRmxJ`ZDU z=;$oGfv?^^oOd3-<@BmHcVlq==q-P9ff?G5HPDN>zQhP3snPuTKyKeHI<&64Lyk#D zWO<Q~zCZNdn?zxXxo!gdVB*Ki%gcJ!xjGj;<Gr1>CQPZL59>U#&0q^L&%-q7`_;4{ zd*u1sldGQ_Qx^YuW-zhFP&!N?aMI2I$MH!8vN9Qn=l{Jgg8Pb5B~z#551|%mT+B4a z+uHF|ppmYEr{%d=I!^VC_o-RLT<$wLOa$!Yonfv#!YxkguVT$6A|@`KG;#`DQohK& zN_rClOT9zQBjlp7bj&fX^P;&k{d{LcnJ*Wjs%dO5Vh%eexZdp)X8^0pMk9JGx$|(C zr1x7UxjQ-tcRA$yGr<sZTLDJ2SY|Yp&?Vd$>>Kv|VDY<~pPHnR+?Lq%<zsN;mu5n~ zcf=!fjj9j`k7#8Kj6ep{Y>*5zz!SbFZ4`V>JSCCg*1K|>%`pS=r<&sOe{vzc*yT66 zyPGY!VJEVlXWu^0^WA_sW2Tda^1@vetY!CqWg?W&$E;$radRh<g9$+MRFpFBk{)K6 zS$&d1(*;zs&I#|)y08x^ZWr0Nv9^cXU|T<;rV4f6ys5NB1Vo0!5V9)l2=SRt!|43% zZGn5Rb)i>)8!CkTD|BRXEsNvyD|ppnOJ-nLEpr5Up?+5$s7*Uw;_)|_MV&CTJmf`O zEZU(5j4-ry%oyBt=nOA66Pkk7F(r%npH~<S8APDJ(c(o!>+Puc2<6VJphAlZ?BA$l z_zBk8U<?goC_ROqj%m$Vt0U_RPJ}^G=9s{i=Wai~q?_$AH;}49#4m;=pLg~ap%{5s z`tW;b=N1?Bf|IjE&4$8nT#01H%(rrgnbrPwDl#Pcknw!Ud<&nEB>F9R8t(3^MWuDG zb8bsi^UyYYp-+5DDLj?JvahQi&QgD|&F#PQX#b@8)8r-A+ON8tO?jf3nJ;z!;>iSl zC=~NPHA*l8!EW?**g+TX!FJ$gxUHKN7S|s6p!iIt77<p1v7$Ckm3IE-Ng<0*qzL;o zabT0(N2Bwze*Bz&YPiAd?CcDEMT(18AkZr?#2XEZi$*gQ3P`6<3#TA64?Qx)uqPl+ z4TTPbNe-*241hTCwZ&$Le>-%rF_}~Xt{g_1*${p~&S8VcOARZk5T=~6mx#gf<(ak! zm$7M>OPxXIr_sZS6f!H+#pYV7T~2-8cSHQgJXGSAm`->DsI>QMvr}1;WeS!H!~w}J zw{MriF!1j-@b;MTg9U&rg5Q6TAo{~BHf6Jr-~zl5KM@aR<FG|OMmkCvO61pdz8j0c zH#NP_RbFksDGrKfFQAjoL5Wn%r*iRVD0c22DUfE3I9|=`4H9))Zfvlbx-;c;5O}{U zhp^ehT15@gI#7`v#~b=}sAf6!sO6`$R0o7NQhUJSM>p+DuT{j1V{WE^BqR<RkBNVG zb<qDN*OP>|ccijbZD7l=QQRoFB{BjJhW+FZ!>ve9Prp(6I}6Umq%HXilz$ayRw`w_ zt3MhuO`sgK*RjX{7*<S^yOWZQdYh@scmui%_HWCU(^r4KmGeye<u3uDc!Zs!@hwus zMvlHWKkNx1aYeZ<2<YU!*Cqm$#W33W2kn4t8ku@i^);*T)uqA(<gykqe50^@wqRt_ z#NSO}K^d*DQ<n&Ym5*FUe?b3&+omKf9PLft@g$PB=6iBA9tA^kRCVQ6A;q}!o#E6U z^6Iv&vC>=girRyjycRbI_d3sqs3^$^5rMa3qcGbN%1kvGM0Ym{H;{M6J$HJ}D2t~! z_<b~$jMJbo@bRuc5`PgUl2X{*++2gU+M-FnzWn0t_JDqab+tiLg*u`o?hmBJ!8b>h z8}^<ael<z{(dsZtHk9@Gq?Yw-H^-&MxZMk}u<+#Vy4|JvEoaOj;`h5Oh`Dm)5D`R7 zEw?@fvn#}U<wk(wl-!3+A&xvG(QPn>6ar@B{HR@C<z7h6zd|Xol|MeE5NP^505OZ) z|Bk4SuC=d3hev{vTNP~7dKQK+@vYu!EQ3N*4V{w}E8iwvcwu1yu>vqL8PWRNG}AKS zbY(dZXGhR)Y0Q$d<-Rs*%v5;nyp=1S3JW8b3mvl=>FHO*7MX{}xZ27HqC`{*G05&A z2iLj^yS>W>znf;9BhG21Icw=)uJpxm9=x@ql|k{<tBIanwS0-ZLl|O$Mo{!U9E~(q zkV=T3d>e+=%1INL-bQuQ+VLFKrMjnbi!4P8Z1+C)`Cs(-sVGBS>pa0$SDUhoF8l9Z zIw<Wdn;*`XJFE5c-9%v0UT6{}D9wa^#11kgi`!CVRzWOdFlR>D1kIA`T!{`|#kG(T zn;7yqAFkoAduoZiZIk+mQYvqmCwfVg^$(wJhz8(#QX}c#7gufZ=4rE{Dg^Y0S2S!8 z%H8>UcD6({OQcCC)ObfO;=LTYHQrU;Xa9Jbm&<=$SJybe0={Vxan_Tt%ANz=2=Ay$ z(H&8GfCA`{hZ3H}?vm$+U<7<2Q;eq+bo)M1C>x0vCtYDJXBcc%aPe!&Cf}~S{O`l9 z=-ZYYWxgt9nfTyy)kYim<UinlQUBs=TnI5OOiEYb<J?v$qy(`WDicKn0#P`<HT!w) z@x>7_aH{2Boh}eZ!ZOI(yrWhJ`7O@mS;u(FUaWtfnI*5l$@vR>J0QZw0+tkk$_lX; zY?$BZLpyGo{g7C3R7f(Y#B(at{*DrFKEcn#<nT=4AD0j#wd7;up1<eMuMLK8?8#B? zuDu7N{n7DvKZ(<1a@UxLT=yYN!F+241iE!$wa|%GGCQ&T?PbTFx}3kv@ne3LviyBG z6V^MwE_vXox{<?XZMjTuWn`vF8}M?N#%m8{u&~@t8&T`F3K^)-NS#>=|AV@4CSvJL z-Hq5appjD;?6^&3*68%!pU#=4eChA{jzi?L=`f0S^3GE2&k1tzo#no6cWb@8ynmL= zP=v#TJ@ppytL;Tvo-DIjtMB4QHcb>~BX$cu^akqwYLZy}ykeZ5({ftzvMumv@;(Ar z79tm+s7b|DEaOi5uX`W?5jwCigteaqCtzu#IBC7(if0My8ZB(FllU~j>UdV;+<vKN zz@*>MJg%m{_P0nW2((ngd`Lmg=e3YmZZBe*A@qfu$&5#RwnZbqzW%tPsIE@yEB4=Z z)yEKv6$Zi@HMq29Ygo*vX?5>;)<L1oX`oFCR?etEM3bHgZ1k=Oi?`SKX2gP$7K^{e zS5@!fI0WOf@m$FU=i_OYjq7Ulfey#Ve>j*W0AcYP5anK<c$_E;nN>)s6+jR9XoXL2 zfBvSJf*TH8N+gkxup_3f#V_&<-3Ouw9qCg4)uSQ{0h>IkFURRT+1?wmMx!_M@8R-p z4ZUL1;xS)-d^%pMQ>D_P?m6!+OluC38su`~B<E=TRt<4w<7}!XmGQP=VZjhCy%WZC z)<+ZGBEj%Xf=X*5%M&2S;1T`0^HWS{)aHe1PX$V#&d7&RoQ`#RcVC}LuYRQ1kj*td zZ?VuSEiIMBtUv}}6M#yz*-VyA6XV~p1-%nKKYuJ8r3!raA>iI@d*bG1zj9`8>cbE( zY2xWdtm#gLVz~fGsjG=ZyrZoxYi&$P$@AAp02o$m73a2<L_+1^Pyf}Yn_W|lySwe& ziA-*8boA|wFA9D(i76)PF^(sU+1c3@i%0aRVQ|3aRm9-KLjVoP$?5zEJe#pb7~~l! zb3m%|xj3L@_wcZSiWHz342S6Jk19g`xc8S|(Q>4#VD@5y0j(!y$+2_wJFf#QEiK`Z zD&>xK7Ctvq%A`Nse4U&)!#5Ss03^)d`U&j&`}?p?x4~bE&B;aD<@5G}%1D4gU~C8H zHuTs>i}tupB-z~A+1c0E*WLZ%`*)^Bft*+W5xHtde*504$>4F_s*b+7rKLepoU6UH z^=ujgKq3P6>)`&yy6{1emwB7Yb^irmH5ooaMn}1J&G?)Vuqo`#4ffk%B?Sa~^~pd0 zQg#0b8^qs!6db|TXoCN7Hi$n_1~7uIA&LD<+rZ#}-~>z{g0UH7_{-cNXqy7Y|6}n# zQ}A!!z9|_{#w)Z&*f(W2=H};L@8&;nHnq0;czJ=a&0So$m7melM!q+we*X~<4^PGg z!HzJO-@IyV&dbYV^7Qkg{LB_uuWaw&z_E(e-rlZUVN#0`Q?=w+Q(Mao29G0tlYcB1 z*WckrZ;J#!ahJZa7r=fBmZN7N{6jzmQ<5uDEIen`Z4}0&KaW8y9-@3uVT$ya-H+DJ zlg3_4dH&pAcLFMMjQDLPEp`L;a&JRyv_$imxA36Y#J$^Hbjtf4qC>X<5_cJ1?Gqv; z+Ane@_U~c_8FqMh=fV(tks1+RWVCymk(zmv!9nw}mssj1e#)y7ZC+*e^v|!jEsSP# zrtO>5h~gELeJs^WQ{UaRMu$JeYM3!VR`aYIM)5pE^SZB#Olob6__CM?dSy_7gkU+i zk4YUQ?vb7ZJJ}V32Nn%|n<!3$zk`F`@7z+%{}3p-<0wS+wgm^&zm-H2jtOFbL_yC{ z)tHG>SxK<Puao!@o5oNcI%%%+_`xHz*bN~$<dB}WXMRKF3x|e*N5LG&*Y|4m5b(Zq zE)M9Ms$Z%AC9Wt#=wJgHaDM+4g7i`Lfl$UrD4*de^w~s1&$D}x4XQz6!TPxgW)%Il z0(>>P>@!XaN0-;a5s!fy_}i?I5xUU9R5ak$3#ine<p)xUSE#XXa6y}weiSx!kbMXb zxn=Ik$tRuN3*R=Qgy9_@wqZ&XV?6a6iOv_)31e5rqIr`)+%}$2JsXywmH1Q78hgCj zAKl<m!Bp`IFd3wYDB^8hzBeA9*SJ)MwM4wyjK1K2ZmfiICiX5ML3iqKNbO>i0d5In z;g{n(#11)Ah^*!Y5FNn+Wh*hcQm~-d)-K9MFW6tgyWBWD1z9os(I+?r2VWJMK%xgm zdf~mst|#@=gIQ|m#!pQj20hKIzPLPJl_tl@Q{P+dj0h?W;z<8u-;_cOKC5cI3GTHA z;gv50*1MmwenFK6bg4cYlH&P|S`<~58AZoz9Z?jTs79hAFO70e{tM$n?|4tuQ(`jP z@&~)DnRu_qP}rca?wiF*fBRk9(hkOtv?POPQ+xcO&wWo|SAysx=u~~H&Mmaw1Sa$y zMqJcWOeVF9E@tN}*g!C2{*>y7U#Ad#qo9%!R|ezCi%(O;imxQS+R=ra7%cfHur>wh ziIJX?0bI5+@Jq$lg}W>EO-uzwTvYhtxaq+P#b#j4f)4(n-AIV5*t*p_#-L5ko%3&P z28EhEl@`F*Emu|ooZ)kA)M<iG1vP8VHrsU5t_};OxH{3lOi4g;sGqdK^-Ey<#5oYC zbS|3s2Zc~e6x|TYit)Z;k6(SW5f|cyuDqXE-?k-R-RoZY><Q-cbF&qXOEKo<qszvQ z8(YmlkJO(&;@JdFK?*_@qK|I|)tw_d9$Ar&7w!#;^(xcvighd&>;-+%NpaCpDl9L? zw_K9|YRdy!<+@D4nwzbhJPf+__n2G<6vdU#*XONXhrvX4pRI{+Dv_pG#_@g|UU{`K z&NwXhEZ)vd@x)9bzuSpt**B~woZIlW6~azWs-6}c*d5fp(Y{t=ak8KK+hv>X!<=|& zqCbh{&KR=fHr@Qhpt;tEUK$>hig85bbAZ5}U&s0^NMc$j5L6NQx;tfX@k|tEinvy? zu`a-(H|7iE_}U-KjEy+QqfMq!$-MXDsF)CK>D>?t`!-2*SUa6m-|nk=nNAZjNuPp? zQG!GCg^w7WwT6(6X~1yOo0M@f{kFM-DB7ae9xKrHwHS=hPca#qw+6>|Oyzo&1Nwhd zs7tg*rS7EP&2~gLii&NA#1%E1A?M1-K+v*9etR`6jUV(~Nl&`+eX(xP!Z??_oWtW3 z=2c9^)}|O_Z2lbW15I7fzpH$h5l{vNRgICw!fxzZR=x%EAF|FJ{B{b{S}L+uW_z!q z1S7s=aT1L0bC&kl(R65ql+jCg5J-eG24be5zW*1C*r(&%vG_hIoCyC2A_5s1DHLjv zx1s1|VG?C#l5DdqW7hS>I4lFyddEowE9X1cjAq7M88@qu?3J@088GZLad5uFpuTBh z)+~D$z66l*ort4L1AHHG8lnQiPc{y3DoN+J8O@R7m%N4OAi3mgyazIOO*GKL;uieD zZV}s&RK+bVYy)(qb!oTuH6IrZRMFgo0tR@Q4;ohH#LD4eo9WS|DE5huxD0=}wjAeI zgR0j9l{#9RhdC2NI`5HUN4RBHoLiEg1yPS4d$p&1LJ`2oq(laB@%#wv7N+r0(YRgK z{m>%RM%%D{Cz8HYttS=!Ni!s%XgW8A1_R_tC?XaD`<+w-oqvc|FuUV|+ir~=kUKKj z13xRu?|Fz0juDCso@U~3P%byhmfO|!=}t4g^!X6TKnyS2Pa=MyW>}WaDG|oL-Lg-A zS@@lGx(E&3oE`-F{=-$I=NS$2FJzeJFBH%Q4%jj3n~pB`+kk^Bi|@yV-&lrYV0s<) zdFanDKwOQ!qA7|7hb{Bx;b+c74VOB0e@HCKZtMb)o{IAnuh=lXk%5o(@;d^brN6^x zAteKSE6pFUzg}R2?TerkFYxxdOlq_4pCch_ZmK@8ebbsO5G|%--;(GiSlJV4m4+gj zOZh*$RzA&{CO3^bkXQ>x`lJv7dw!R_LZ9?O6=2y}*p#j=P>)(vR%jaeCqRNx4QWpq zV0O^Z6WaU?P2HY882Wt)1ss=rzNF5ifX4{b{fG*}61dnTeZ!Q}yZez;N%>r~(yU?J zJ%ogx9~6d7EJ!TmTgZ|fm&Z^uD2EO95V^CLkmKQGGGF_7tt6Qemqfz%TNilKRJQx6 z<i|dfQ|wdlpfCz>qgsEaks|T#%O^&(^Gf@@&DBH{U@7mRe`i!|n;aO=)|9gLu*9Fn zzDZW@xE9v}9wZ4@vNl!a&U_wXI;QxcDU+5lT*lgKixb|n@^wq3`#D|HX$JkAl8$ew zLoxTKC_X2?g{_}+1j?m6<ATWGC;_%_sl<!FC*@!FpYiw11K^r2EBx~>GkVxLq6F}i z;iv`pG#Y#Ue@Ih*D-4Ig7Ro(TB;jRw{&Aw|*PbHGbE~!XDU_}1Vm6GSH$rf7U2<F1 zs(4~HI8S=T<yy9&bn^{*=}hJ%VFTAz#@GTO?WH^A{3bWu4hFc38ZtnT!ysMxMFo3A z-8ij74J-i!=c0I~FrLm)^L}^Wnb+NQm+ot(mLBRRo}s}xbJ?sgffmC0lcS?p3$2U9 zQ9>o3dxh5QnjXk{k%z#{eOFEkbqaPe+e|H4s`WyFQz>p%eh=)jzf{%>275y9obKN5 zz0-DHq_m@k+G9eZnhlj*_jn>bMSy=2i0xDq^q09Bbf!kO5<A^T;~Aoh_DwoSn+Mp* z;rL#`1om6e3z*StD9kv7DA`C#N&%}FtMA}PuO$?%NYci-ye-ok>KPbz+_&E`JeBt< zUqC~6t<fM#fv?3yS|$@(tGCF$_{cs;82-c(_Q7bkMT;H9LxGU{)z`{kgSMe3Ov~|c zHD??e`4VfQU9PEGI8|-q@f?RoJRa}Aj%<NdSTxRdjgf$fr(Z{mYPaN80x70ToeTN{ zdu0Gbbcf;UpMq)m;!b{ZgTCWzr&Una^bP576ObwN^dTJt#^$0e4__7}KZGohG_V_@ zUJKA1FhT_W_>{6xbxYLR*X?l_8fr6ksEuOJfXnuej~xXenjeP>UK6wVc4_a*l`!`d zRZ$D#lqGIdg^YilA7}H=&KscN!WE7sxXi-<TCgmJpuJjjGytbSwce<3R{bHX?1^=% ze`3sS!-b4{koO{|eBic31D<3ny|_E}^r6j0Dq=nYD$hsR!D$SQXJ7rKhLCS9(D4je zVtCDotfQ*lh(iRf+)bkO-3>h^7{W;XE^RznBTL40(SG8Gq9weRr9KoHFk(7zIg{X* zI8uD|c-Y&Aie%MjV2pKmVO}G-Bu@rw2}KPMR8~If3H7ZgSAFC<VTP#b+9QLFmSZ_9 zFk&T=^zq5w&E9YB)#Nzx)Qrx3MdiZ>CHx?Tnr&5*>(rQ$f<-iHX%1cam{&@XVzclV zAy7yj_ebbV;xi+yQ2D{G3x&B$(tz}KY4aCLg=pYY0tip|-F2XukZ?~?T>#0^5(z{% ztNur!MqI9apk=LBeXPj-b_c0pkwfHjy+}ehkNO;}JI42GDHJKYYK8{<jn|QrhkBRn zRXgXDYUxl?NBVDzV0$#!0<@Rh2p2@0o{bE&zEXylg^c@o1<#m|6N5!J!WFB(iCjQ? zi%l^=r}h;zl7Yu^^bis-7x5UpQC|<9dCU_EvrfN7z-%>u0L=4ZMRw9~0-s<XzVxtJ zg9||g{~;ce#nrb3tcu`}u_w0byWu33T4z?lIA`wtS5q6p?%|4ySowXegXRZmqSI#7 z;8k0qYUP{sk~P12%)!bftLvW|HfQ|8GkEI1(ZJb_WNR2T_Gn<0$@OBS83_Y00q6AZ z_ny$+qIya&GrOlwPiR$SJPueic)bVxcTS)Kb;N`fx*qAow_vo_Cb%F$i6K_q4DDX? z(PkI5&y#-?7o(4;;F$+Jp=bg;rXvebtrmsOT?|ZRQ6^pr>M)Y7&0zVSzBgDIR#1u_ zeIcVV&``QEfh<%@b_0t9IsS+ck0xw?%j5o~8cw&*PUUHD5&jMx`YZf|tP+K4`$r*? zVlF$sm#9E@aHSH*ewY)7?(JN>2M$HT;_vq;!Gm%o&d88&&p#>{DomE){gD{z<Fa>; z#yqM0Neez~UVr;B*%}pWt0v)HM9!^@78{MH(_{WQ>8Pi#h5ORQ>w9yfq=_SY>o5|s zyQ_`P3c(`_lo0Of-M&AwdZt3xJd;qFZC3=j9PaI78@h4j2gZ@Ukpo}t8i9wvodL<j zon>o2dYCz-Ll<koFg(n-kdJZ(ySP;7#gnz<`rCQ;zd<;vq+rO|16(MXJGLCEg{u_^ zAx6#`!`wCh=|<SDUik$#&6S?y8UW?hDyKpPHPZklrhKo)hpfVn$&)+hE3OP_vf86$ za=-W_Y#@U^b~`_-$lS+qJMNVk-6ySjm9}}i{T*#`A0HQ@=8=gEa)%iqfh3wfJ*YDp zWGpURkEXH==$hUAS}iCk2Db+FFhI|XP5F=n%N=N#%@~3koF1p^4#{aSfH>%Zu-csB z4G;6}Xv*4{<`5GiVM%NSfl}Q|XfLZ1NNIo)`aM$lA!6W4+=ihBS~o8t{(~}0tGCac z&!cz#IVdtsgMxkAZ=!>yqcsUN2msG4n}3A_Y{0MrPDvSwXgbV{^;Bri<@sd1w#e^K zc)agHmY%&dCKcrGzyQu!j+gFFo!R!77xk0CGdaGaLTqi^NIaGl#$ApMV}DkCXo>Jh zFsgAxII#7WuzQyE*gi_AS5AMz&(9TYYb=ZKR0cIxdg)z;LdIU5kNdmvSu9#149?#! z_RteyVbr&iq~27hK*tv*aSlcb=b0n%mzm8@^>3h|@o$_s^E&Lrn|}t>-K7;G0blBU zcxSmrcriAsY$W>U<+V;l(kvbTq?0Zx4q=^(qe>ObKa+aKJboMiNid&>zbW>6NnQTy z?oV%@G(9U_RdmhoMxI1#0`M8OFOsRiXU$5rB}`5PP9xc%yoQM8LC<~HmPk5-Px0}N z>&^TdS%W6`8`6r+@Xgu_j8rCKvTY8eSi=$o2+O_5;gUn#Qp^>yTD5Yx0`Atsdb{_6 zUqjG1ylJhgN@}{;!pf%~KJ)E(6QxkkhR?an9wWBBIpnCqYUQDpd}D?@a$z?T9)U-0 zD9FU&iqmn(@6Gr1;Aasem)pkl65L@X{_ry#E|jrbPxbO`XDVYEg|`c<K5sD7J@-5a z{yx76WMKcfxJ%<y=@}op5*c4(k@cA$P8@#Jjk&f~<I0l+{7iq7X%M@trH0&C6s}2) z|3ff}9dIOW{!%XLZAkeir2jOynxs_d)=Gk$g3LSLQ0oWZ-Cr?x_DaN>1XMiQv%y-Q zY`eGXj0LArgEV`#^~y1TXqwmD!xqaGc%IfJekHtC9s>wEd6Thg7R-;m1Ao>z5*)3i zCHP1}8fUai1t*xv6ucu-|Lo1%W2OASg)(d2DuD&c{!-}!d?qwb1J32U<KrK`olLeL zD>pw7#Y7A<ht?rzi*wD7_v-g8Im>?nTAKG}!-})7S~|l9Z(@c0;qYh?vBI`emTdtj z?~EBXWSrCu9qcF&;#NrxC%#;$Z)c5vjub0~1bSXrJT?Z9eCCh+CW@Q844LjBreSLE za@_Rsa5j$GG=C=QPzi+!NcA<1v^1@6v!=@pFoQqY{2@H?K~pxNKl*;&`uk)Lh2F5} z%v|54xwTi!z<`12S(x37Cmu)H%Bz))r3!`A7lZSR;I;x8QsX|p#c;|gq~Cr*%HJ%! zkb$*|o^_><RC8pUbQTl36=a4|6h*TU#TN9lgFbs&qX{&oGu=d(WU|$ayBwyV*L$K{ zQ%4lusJC-R?6Nx2qZVioTS-QM{-?J)sy#l$^CeC$mHT^C%`bNVxkv1VR8zc5W{p$4 z=-026oEJ*pv}ROB=lv$mUH06ikB_CzuBydyrurGRsGz;^UA?7yoEj;%oeRV-BsyM5 z$Ntg{eo0Ow2NfZFKzfnuYD4tB_qQT73M^k7^_jPzA#3wxrOS`eRRPQkHelP{gFup; z!`!Z#FKT(!erFoRFP?*dyv$cTMVdHY)Cj&Rg=1GP%Db4&&b$irxlI3j9{R136aUIk zB$2-7VbWi5)RVF<7SME~gQ=Qgd{t?`xD|QxB9J5}WR;%{#B-{=mKAyvwx5dPm3b>O z8M_p*TaxVCHE;FvvgB;M5!L>TIL@zTDtR3#mNmw?5kg)Ap-9w!)58#h4ALTz4>BPi z4e~`vG(y})j1N&o`+z<)1q0kJC+N0{?nE*Nln@R4SMr%um&&2a`-AiUEk}+vmL!TP zgSL%nxHedx1da)j)A9f$Ta4B@e*g8B{aG}MBp&l;Qid78N2x61VuXl1qDo29e}W|P zGjG`tFK{4ov=DvYzl0bf17QPeOwo=u!ot4^#1Q*0;f2WfkI-<*|0qI={ZHcL<9~$` zRR4O3D25FBPhwg5ze3M4{`sp#@*j8PwHj0ZD<sN=26nFQ3VQnUaC^}mba=E_yJ+%s zveLq_sQ5qD6-op?`26Z#?|RpMzOh(qDj9H-Bsm|6PuuZlt=)U?uhd`njewwhiFbZy zn@F_l?cUt~?EPDx4)m>`-xrqu*o1JAJcXBd>ch+XqtdQ`TcUp+|23xbKk@im0s}o3 zF}Xp!aNikl`|DzFYVUqJN7w~k?D$UVV^N3S<>7BruhZT1Y#tnxn<uxQoKB9%>GgHm z+6=&tj!eKqB@hEGP^eEvA|;^2Qd-5&`y=?<%?PqSYyIbq$~~u=$#H&{ygB{JOG7(r zO8$ndqt)xj@uj8yeiT(O$^GNw;JFA@!Xh7tFd3T_Lc$H%MALus#-{i7*0&?ApU9`s zZ+UGk5AE{u65??8_~;bf(jrXl>gHzd8xaw)4Xx|!T>JLT*2=)pFfk#a!<>wa3^YD5 z@$TKb__(;uKAaQ?znIt&5?5_^cfK&*jla7)@f!7%yPx0k{Jf=|larH=1Q!DXi4L6A z03LjG<pmrX9)9WM>A65eOicWtxv|kw+Qr4?^lZ1cH{?gJ>Py6IR8;g>5)U80lP~iG z>S||aS10b^;Ls`*jx*NK(Q%B$WhX7g8?#ABuiF+C^<5LrY6<UY7n-Xmn4DBs-DGJm zEi8ieo}M~8!=-i#blK0DG_hU9!{im-ydjhDo|&7ouyS^Bsl*1&PfzoRie@XLgLOB% z)zsCC&NRD@E-vQAaPaWnFN%Nq^y%y3;_%QA1Q_!8_>PP)SsWWAj{{0fOnmR-=eO;p z_5!kee=l|&7!UwK=H8*q$U(+tvHkS@6KW$*1vaSa`G7Ii6@sjAZuPdX=qK7Qbrn;1 z2@VPVb#c*e+-jw(?;}JaWF#lY@K0?IQfPHGr}IEYGv~?8O{V|#)s=|7=Dj!A6)7|3 ztP$Z)vjIXY^$ws<d7D!90aY_;Q{hTSGbg%KkT49=0)aqW2?<BF;jH#>DdnEft}Y4Z z+z%gi0jNPu?mv5a0Hq*%e2|!!7>&M>k)>C;5M*^@!+&{vJns2+Ip(SehNONfeBj5A zl0E4Un~ZLRgwY7F2tbTXP2ER=B`0!nayVN*f362q3W@h@tEsb#OG+-%fO+D#2;<Bl z!C$1CdKpYMLVCx2AmN%@(3R&GY5<!*sTezS)Vm0~qS+uM{E$t-@d^oa;GcIIMIFE% z^7Fg9FI-r30V4C%U=tG)@vvBx3Vcv2Sz=XHm8--&V}E}?4k+u%gkBBW8Wm-UP{9WQ zEK`HVr>35)ekQ!t!3YK5Y*-~q%kFV#&bgv^PisNx2e1l+ilk(a2S^D#qCi*%qS#E? zzQqO4oJJI;m{iOjf;`j01^!t0TgzLv87;$v0<?MZr{tN&`21To@h7owtsvj<V<P1d zU(yhOgwzb@4?KtfJ;Z%Q^=5?`LGAMN3_?Y1OOA3(Qx|6?N}X;ijB(z$h%NQQ4rQdf zb2PwN9K6gPvg$0ncksMhud)qa)Z-2k=cGZAFl=#S-rF7l1EpVh0OSAwNok0#Ah<6? z%^!Q(QE0GX9-c$dY4S6^Uq({KqfOqneeIK7MXjqkGqE~8QZP4WtbOF5qmX-kyaMBS z6-!)#JP8)ClYK=~Il50qQM;+cF=^m4eZbBF$e2mF&)fwNMF*~+1~seke^&LcgNRs! zm812Ux2(MQ5vlTp2xQ-BBF6nn$GDb4PrhZv)1(!m_5^+m?7o&RRzZ+k@`C*`f|T*K z2T+#K&%q|q@}!tRf0lk`-IWtBw!S`f+XgYNuwJ?;_eZ_NS9w=Qu!c|9_iMiWTbh3h zTlJfZ(}-Caidgv9I@##<!l#2yQ<;24D~iEpg)d4>f0`fL98BU9A}BMuP{bxBB;1?q zmw3ihJvbIm<r~({1}xNTXopp|nAEM)B+q7X7r__RV|~kDwr;Z6j%*DK`lVDlS2saD z)1q;B`T62FA;AlB6EN^KMbs(_89)bSrve2Xy0uZ`VdNQc9;B!^MLh=AwiU-JA{Ye= zI>4D}IGPy}Kq?Et?E!+}bO`p&2g^(v@wt{J7lPhHC_qqu023L=-}`BQ89o3e5W)Th zXApq?-v5E%2mye;Py`FO`TU=%9!amGOmu`Yv!tNKId}tBS%Ex4YaIa)&>96pFp9B= z5{L~=fGZ)e4iF5g0{{kOz<^B#kU0{76LI_%>X8QzAOj2#2f<&VSV$Bapb>G<{S_kn ztIL)U@gdn?A+Eo=rWrth;J-pfe|7!;3jcpd|IeVQdMU<=1V9fZ-{0^Qbd*$tD#+6T z8vjdoXa1wJIseOcGxu%tBfdps{C5rL8NfyY7$ESEXfMGC@*80O|8kl}V<wyUZKIhP QqNM_q<<;eCWT2t{3-q>z`2YX_ diff --git a/spec/fixtures/rag/2-page.pdf b/spec/fixtures/rag/2-page.pdf deleted file mode 100644 index e12bec20cc449b5d488a024bdb14cbfdfc7e12a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13529 zcmeHO2{csy+m}Qpp(5F*vSyjZ%rMp%24Tp)YmC8QW;8Q0)<U9$7F(92NFt#kl(K{< zZ6r$yg`{LDWa+)5`lb54=l#FGbAIQ%=RIBL&i&r!KKt|BuX8@njI@P5Rtcu83X-nR zt-n!UUY`wugP~xmyAMcH6JkoFcrm=eDgeb2f+hJeh%^Y+56>X#6A4rgqP8}O&Y%(T zWDqk`DZUj{2NlqtnNjdmO6wJ!KgeIgr<)V!e%oO0?jk|eHJI8Lsuy_f`L+4mbNA)W z;KSMm$p#%A)9g`#8SJNGchGfRmr%WL%4+q<xzj=7sU)70*QyE=*FUSgICqOs7q!Ej zbRP6d=CFv4t|W*1%i-EGHA2^Q@|d_RPBA~TvQTID+k!7Uf<xK*w4@$@h!l@+t^q4n zG$2+GtOX2#U_D)?ss0wK+W$V(jPCQ%zsFhu#M*TazZ~n{AY46Jx65`pRxu#fqT8Ew z8Z}%{X$1~;pKtI?sWm)8KeufX84Gjx@(wwnOFV<HLKl_n;>V6e?c}Lz*Vs%I6TRi4 z(Vr#MB=59;y{r#=z>7Fu$>%vz_x~nTRuI323WNO?D~wg?zpLxFy2HR~YHEm;MSiVD zws7i99<GtyIw8+3%HE8%3+K!L@pBR~8n!rqS8<A-O4Oee3N~Vw*Og4+NI2<vzkKh& z@weS(RmNk?lUGlcQ)E?wI8>!i`I&WlR%iL0WZd$6+!*gwk(HGdFH1?^S54*#dEc=* zYEbG))?$Uw`>^5p%#gN9yWm~2vU(P5R^Z6({K=bMcZX`REoiaH^O~L~`MTW|U+cDt ze+iF(ZPuE(xmuFlTbX@CIU!zBd~qf|!{P>*=NO%;CdKvTq5o4(i=&ez(nk+IpE3+! zD{SF+lT0{29%gq&FqUd~gQh8QZ2ZY-{KD#3A;Z@po0QVU*$22ANTvygmOixeMa5>X zYBrZ1C}8m86xr+%N3)F%ZM->*b;X2g9f*bR{E!&W&OzUz94kUj;9Yvb`%YdiLYb_v z1zg)HhzaFw=~uj67s69r+BT!SF!2Qr3i~v{5xPmaPM#^sdyz+#31;79<e=>uoxL@A zJ>OW=_|BnC_q4gLG(TvW>qqq5Rf^$~h!LKh06`>T>lU}P2KJN7xWgkaZH(cIl~~&7 ziYb%?lV>iOavNMfO7mw3e-!$V>%Vay_bt6wii2zS)vncvoO;FYh#S;4S9-^sZ#t^Y z!}n614WFi;#z%f@s1ws0{bHb4;(*JCHP5&QTM@2X&EtaXZ|O2c=%07wcL|-UQl6mn z8|YqNlH2AM0n3Ef-dva8kd=KbM<*l}V`1deu%r2=pxrZ`LvuQy_;r#;-frbPw7FR6 z@#f&o;R-ENu=^#R@#QvCpY!LM=Z?r6=`Q*3c5Z$+s#~j@>V8d`O-+4fd@3l0lk4+L z@aIos@85NGJnwk3FW{<1pnCVdtCU-VpHj$96$q+NNoQvVrPspcnyaJNwWLcZ;q?a5 zb3v8CE(x~VK^#5>dJD=1MQ3Fy6f31F3yV{#TI$3Dt|{MrXLrF=Fx2~EUa>Ongh%w} z0?T;5AmOgdY8JF<8Dm4+$E8xnM;RJ^UdmDz7p`K?Uh#P03aQIimy)z8rZH7xkR4+3 z`VBq?>m>x^QmO=IJk$m!2}eEDbuPR3@V)NnsAITlN6xL47tL!xbX||~-}!8$D$J2B zY%4uZBjB#?ohzMhN1;xaYO1;^c}Alz6}^XLk~8-iG?_hKyG|VU_)T}^y;RS5=s<-a zN8Xvx+l8_4QBQq4nW+-@GI^wBj3xcW<re?pt$DVi{_-f#Yx(={nm<^ds{hDVSUP=9 z{N=vf!tqPF%{!#?TV^%(oY{6&A@_wZv3{)4*$YlNT&1#fRI?h2+_w8%(kW%RW+~@1 z$_M77hOxR<+51Q4O|<Wf@SW@l8-4nGQLE+R&H0;0EMA|-3(L>Gka4Iub?Lmc@B`V> zRZk-C*77)AQd}?VB@T^@g<tE;xFdfw&5bSQ`H&Td&fq;I+O9)eynK#d$$g(%Qm(bS z@95crQQlq3_hyY9PKngMugVa_!gm<lQgzBREj?WREX+B@-THa$TAQmKX;STu#RtOb zt>M@Nfko~4>pobnj`Qx_+j4|Go1`ljM!$Gm=UH!I%1Bk}SsUeg=TXD~jIF{@I;G^? zp|Pe&19c`$usX@Y)}$?Ow5-JW_$r9q?vA96cc9tT7gO8=C+G$)!ut(T9G)iKZ^O9L zHy4{y&kvoFdB#)2E92aq;9~o(g&B43$Q>&0K&5D&>lK^5vn6%fcSR>l%j<bMoMBT# zA0}Ldr$6x+X@$%LRTM;c2;a**-x6?A_^xkTdM&5*v`z4+;JveaYvPU0P&lN9_c;r? zBunIP*wj`~v^T4#AT<rYTcqB?=Ym3eP75qFdbg5q{-<ICUo?+_#yc0!!UyR<o3fV` zjBY_rD;BuKDc(4LET?J7Z+t*1r4{~kx?Le!DqG<Ek@`Vm2;oh?gYC&$dht^q|25@` z4rwwa+d?+r?^^ewrY2wQ$P#nYtERXILdr;3$VVQJw7tg2wOfKJ*o&gYdrUpUg-TW{ z^*=X>XXegDw3%v%jX`6mUfA6Cd$1xOD8rAL*_wN566batl6b5#QdD@S@IG4U_@<D2 z@mCGEwZ~1c3$9G}hx3s;5AM75MD?abP+zKCxVQhs+ZT>D^SAJO7D#T;N>#n}iY;xx zdA+Q^Ce-A>lj%4FdxXPQ6BSQ!8@eGc|FLs-X<~7YMRMO<xhjtvB#79zhy^*u^c{aI z#}8UFg}~<u9h-!QC*GHaTI~cK4idTc86A{jYBsEX&*89qlE24xO&Fv8<3sIn(^cW> z8FNPxGX3^cCq<V&n=KtUtNT<gdnQ^XTvEf&;z(ZY{<Hift`y_$h=^ALazU2O1+PqS zSSn$<Tv03LQNXjaxk+a}3q0*R$NzNI{7qZU6ITahhmKC{C`{smBp;pV4A>%8W9VHp zD{jq4gbt@gZX~;&&S|jNeL7=s=Jn9#%|dbEZl!yl*(nYQgVxvA)D1ild}Y7pupNKM z8kqI5K!xT!&?F*%+U6Jv=k{`16}>ktj@;VfGSK31f4H(6R!ry@Sj>qE6>B_hs*F)f zSl#U><GsW8N|))KhnAjATqF_<S$fNCy$AhTd|J9lg|}HnvVBHHItt-bKnh5Zdap4v zU^Ls~mhochL8sgP&*(|R$-ezxVlIRZwst0F&-?e0C=`_M>5JZ{vzdc#P+6CC<A-DJ z*wymb@CRHyHEm-f;%H$}ykWw(wdzJdNElIC=7~t+gFR(w2iYHau!p%8;07n&>^jph zxO%Xi6MiE?4aLvFD=~z}2xpt!QTo)6;R>4dQ`qu_?6csvIzCTYae6)avHFuC@!WU> zwK(OeaBTaH=#e8vcYS($Z6~kL-A<)tyCr8Xna!w}XO8v?irH7Zd&^NX`|!h?gq`PR zN8LjEKTk|nVT>*JjVxuI9ojv2AZ#H2acCYkMDD!(him2><2Rf_od}24Gb=TktrDUg zs~5{V(1IFIDN*s!(H2Ui`@Rac!|xeK?n}HNqnu7ZbygxZXu=ZHN5my0k?A5@+E3Es zaFa(*hISaA&y;#6r7J7UIKeC1H74~o%j4Qf@61<6PA;50c@NDE(rJyBbe7Ld=8&Cx zXdHegdjC~RwOLq(Qma9>i-6;Mrzu#3bSkehCQBbmtLV&32vBUPU?zcHJQl9WJ9u(7 zD>q`Dq%(?FQbIeFAe9=A;o>V6&|Kh-^sy?z-Nu!B_1rEm+RC*0P}8{CwL;g<IKAi@ zxG*mEYH?N2RLZ4~xj_o6(?v|EJ;52p)(xJeWy<n*P+Q}jm{YI7I}(;&-U$!Pcy=v1 zNqb_X;Z`{Ly{yYpik7+7$NJR5$0@Ck?|u>q9A2=~>0FpApoRs8%T#@_P=gN$)y#gR ziqv>t426oLbr8a23Hl}VhYNMZ6%SmXukMh&g0)4cdy5iPd%LEB@B<NqCO%oo&=0Pm zd2D>AB`an{Y6goVtP;Xrb0n76G+30Cmx+CNnB>$l@h~Y>NI)nx_ya}$;row*No_V8 z^G;}58Mj%VDA;x4g4EKkfdVND<9#pdcN>fB;Jzf)xQiCwn)A?IkD43mfNO21H;%{L zS$Eb0DYGHbg8M-Zx0RupGzZ<|xw9GKQd47j=(U`F(ZHpFkE+VYn->QA)q-BJ1y!rj zpIT+!x^MYK;?dGl)ZC+R+EU#W6|XLcsL%vdymMc_=<dXGI4(zW_^Oek>K8ZB4YIaF zxUQZ>z24L+wuS@LoXkFHx@tm*U;oPeQf7|Lg1|Xq?PSQAU2&au$;Lg-9a6bQ69F0y z$S<OkA3XVP74*`kAg8FIt((n#Ltw4pO}1Bd+n-a|0KHq~t|A~DchI@?!iDQ<kk<-r z8}2n|zS&OXJ^esIbAR~h_T=3kI@-3ADvzUiE^um)V~)L(y%BF(^xA&=D2-t|F>QGH zmL@$XWHM*ypc$=(IhNHL=n~p0B;d-N?%BT>rJAt6zoEO#>+qiWzKV@H;pD179kwr8 zMshkKB{vzJCHVRA+b0&42FBI<f(IKjhHi-JoC=OuwAgw<s{M(>u#11^3I57AAFWfL zL}q)-U&m;!aS>sIXlt2@_s5NnZ)6jmHJZ)*z_YYy*<0-RXt3u-TG(#R?KKsWJ72~O zyx11MSHaltZfBNBIQOo;f;#q*J=W0%3uCbtOE`(R3niOD{oI0Wt5%0%q67RTv8mA? zOVZm5{gnka<b#HfZ<)B@uU)jE=GJtf<IN9rLFI#<yD!%nSZpmX8$phB9vE(@woYT~ zh~1&M^ElQLzoFb4RRHL1w6<R-m>y%J#<c_KW`!3Iy-=B37yRr(*6x01`*VxYpkeND z?=L3NQ}~P}Nt^3zysw4|{kQgJRgV;|MGY?%ZHI3*HhjNs4aox6ir_aFGs8C4k*Z)8 z7*SCa@?je@)K62tXYj7RrhNYP3HvbD`TOqiS0&9Dci%X6i?3^+!D96s9Mt)DVX^ZV z!lpqQ&1(n$ku?}&M?-4Lb>UkoNv5#e(r~M=sr5tm%Z>{qlhoz8FuTz~eDR6JN26pg zgyYugN&4<m*qBkS(E>wUj5}#ZQzXORxRlC3Y+Sq79h_v^Dko+h1AAO8d*Zk?bmN{r z%a10a_9^iZ3~#u8VCx%(oM0mQwZP}PX3p)U%!?@!+=lG<V13-9J#vD=aa-xH<Xj3c z#}x)$-AiYs(ldEF+<VX6wb&tcIR249PuWGYCwTOIl=%9*lgAHeYKIG$Y&JXdbnVj- zBPnrg(<6%`@43Zu7AG!<&EvvQ0{8CA4JBdE9`*=5(PW<rlEUO{Q9l_qY2=}K0_%%M z%X2{qY2`-XJ1UWf&v2t{htrK3H*GySUEJ0u8o6Z;Dx+X_Rw~%@ajAt|v!?sAlR0~> z^_m7JX0`lu&P_;hikQZ{O)=0GNzB`g#J%3bubR+xgiGdZ5iV_L+TEW97B+NscgCGd z_P(f{iIb&N^dyR*Eg^+@+<S<lkIELGFyEXze9SRX>dHp(@jH`E_HSgEVO=tuRUsrd z_Nl?fmlt+em<VamtrlfE)|`i(Fv=gbIzI84NxRoK{63mD_V$)GMB6-=L-&jtv4MDy zoj-;{ELtiRv^}{FoBQ<Tl?@_$HsL^7a)O3SYNJ-(hH8f${gNx6t2LaF(BbLPdE&TT zZ_!7<#kqQO4yB#%OtfpesP21OwE(A@bY!RE6R{}nqZn)p&M3!rEiuwLQct|?LD_`E zf(AOtaRA}&oZ*jpVL5eW4Eu27>r*&7ZyciK+$}EB-uC&cp%zK&CPwp)Z5psY=0q%y zv2Z_N5_DarRX;r>Zf`-Y6LSocc<zGa%Vg&y9{--A?PV#0YhyYx`{L(tlWQZLse7sN z(+d-z-YQj2CgWu9Fa_tuJTjFTo8KNrq@$Y8#cof2J+KyAw{QrpI2FoSiF#X_b`c%v zVyM%>XA?lYrVM*eu#7+Prv2ia#B1Lp^YJ-+ErYGLL3z}cDXFO<F9mS}N_s2jG3|u? zh8<R$?-Y52#a|d6S*w$b4`|v=Nv)FzmFRcbFdPu)&^aOUqIp+272Bbm=d2pxpE>Rh zOANSy@{`>vAs^)tK51w{$eeF$+&uJr>s0rlcE|iV-px~j)`2Z&asIo+uQT0BrX(!s z!9snTZdP1PyZ>^s&jTquJaImyD`CPr$|U5TXNGZn;ps9BiQ$<?S|%TbUoWQI_de!S zIDarRak4kp=JH;N-TYt51Rs2|6$_AMYLyMk_uPMzNW=}z#TN#1hb!cD*q>bpJs89# z^K##UJE>{pnVZ&l{p)<0GpA1lBR%K8I40gnektr6u6@F!R3Fyp`f<7`1^2w6Y^vJ+ z@Twb8!E<59E#{jpc_#FEL}aCBjNda`XmhTN^Hat0n@=$<HB&!^@)4THF?=`AgbT$) z4{mv^)W|6I8eYr2YAYVjS$JD-p(KW<p1+IxQbU9}*JS3bpJArfTVn;&9A|~cf#4eP zH-RsolWMQ6_mXUTqJ~tvvT?l}zA4}3&RhP=2JxzUr9<I9qLy8Bp#hiKG3#tfZuN&Z zmJ^hQ3GVQ-^^ksY=uLe@f-vp1deD@5PUqcHoedANo7=g(wE4s7ml`J*u9<WmteJ^< zv41J7v_H(9nLW~fapL0e#V<YsqbhdvNuKfJ?6c<XpDPr46vxY5B|a11YGC|@1NT+- zmkm$06JM8of^UfF+hC&9h*s}}rlw`2hxop4>Ai+tY@c}$>NcBUH@3N6A;pfP)%3-^ zQsGDYBep2pE3Z8*yJ!ybYTHyF)EYD&G|}4?+}hQ)t93AuT;d!w)=j7ztLwc-h?bdA z$W5HqQyB7paP<0~pl~nluJ|7KOP^X&Q&)^i!W04*I;^{Q9Zus-fZwK=FGpVKxQ91< zYF<pt4AswIdXMI)@Ex{ldLSlrI{OAfK35Z&!o25sI3T>uvd<co>$@?A_Hcc0AS2P5 z%$Sc6Pr(ax-@05|cKJ$U*~J2;QJm=0GiP+K#**E$w+NZXrRrzZys#p5#Z4U<PB-*t z($@7J(l*JyX`bnFePHcJ`$yfy4aEg6?iJOIk2@;wyN_IDbZfjA3$2)1Y|HG&O(K@6 zvjg2|alC=?&)@mmIX?HTx`)X#lg7xnZ-JwP4{=0BQzExk<7>fNc<K)kI3KZ*!KTR@ zl}<@}o#*26D?jb<TyKBk*_koBX6`(n1D6tR&L?GV0`a`-8PAe=)mk%{kBgv_6CbO+ zv{~>C)7r(qifi?GerR!)@|t`|@~)m)FiNy;)AdMl-Ju3dfudl_>>iWj0<OE|i}n3X z!Eu|M$wy;2&Kp|HTY#R`rIi<Le1a<&x=!2Izq2dn%c@Y$C*Z$<8JF=x7S4!-!Bqdk zRAJwNRscV?4)P~Luv7{|pGYUrNd62efFA?5I6RpM(bHue3Z^7?A`Q<VQ7KkL8p#vH z0+_w%U<9xLIQ8_XOeZBILItd(0#yOS;Hqk1v>Mz6V$8t%kqEjJFFzs}3WDg;2}BA5 ztcHexAS`378zndl1%hDk{zgQSmp22D10Gs2h-6!^8X&?_4`A9vICvQwwFCi3=XYQj zhWHIFg(Fq|2MFBd*aM#iFn{B?c68G_{G;plhc+H8BW{gZ${|VK9+46h5hU;i&5wHY z=<&5s)5b*?54lt|wRJVPp6k?-Qg`&)rWhVCGQxH;Aa`TCeHKNqFW5GsWb9pqbZu|t zqY5QFwX!1j`lI@&iPgOuK5bmD|5he=_WE??`%eCvJ)Eg>vX+*s38$xW>#n3n<h*@$ zs<)h^;n|w~GHl(>HBUR11mCS!)Fs8XnC*0IlO|ubGroOl|4q+8o^wmqFonXRS2u>> zS9WUKMVo7=Mvn&;uj_1P|LFB<(`ZK4nwp>tS69f}Ib(y5A_Uw)jRraCg8?6^hhAd# z_Rn$lr-v+8{};R+hWwGjzxn`T72wWbHH9@TfVfQ6!_$eZO8ocjK%&tZ7;ij{Rc2HC zx3ww=Vn^~|c+;JrU?dU+MgSdxghH3Ea3nwj)~wUzYn^}eA9%SxEkO`#sttw2nqq*L zi~V!?Q9pdg!obU+{LJuUa`4pv7{(F-)LPJ}1S=xL2?7jOAl5`C!{w*G`k{9@dJDW4 z5n_R-vDy^|Wodv|66w?c8i7a$!&pWvA!bAm5}x%Mu%4rUmrfN8^uIOUoer^L^)oGi z!1$_y&PovmNG70;4qit0SM>CXnEn;pz{~ZQ?Pbqb*oJ){NqpZF%!mxU2cCidt0C;o zOuvtt5NK88kEw&E4$~h`@Fg<9?nEyVMQhu5^{s7Sl84qdJER%Z%pXJaCK(0Oh*rTk zYeKLu0qwC(M;oNcRA-X?$$%9wlk7*Kt24E>;enwKQ5{&b$dGN?Ah0IQ!&BW-AN!35 zxYOF^&0zSeLm+{Hfy#j@%2b*c1cpYVAy7C34p#y=l;}Ye2A-)zp>JoY_^RW3e*wD~ zn57f%P7PpaZQJ%uvHuFi6uR=VBgzCS83H_jz?4;%odHw<x9UIxc?1xMG;LsGq!G<M zJxK&27>-niD?^o4z;gC#C^r;B9t;7me2$@#$pI7+BS@2_@`nz<zi(Rgv9?4S9q>;Z znD%|+`axsG0bx15B9Q+yI3Uk|W$mZnn0S96IOzA_06ujLFf9cB30PC9e%f|45-?BH zg#1ch_W3U&Q6CsLYQv#$q!JXa1VdTFVCqn`I#fl0wScY=|H}E7v>DZd<QepzNUz}h zA^ojC|0DU8y#Gxmzh)O`HJ~*ifAuCS#s5E!KY{#TtK*-F{He&llz~63^)Ds%AFKUS zk$;@Je@XC9MgFD4{$sU&D)Nt0_b&<lsmQ;S*nh0{PeuN5>i#9cKNb0x68n$U{{N}S z4`>a*y|uOlvVgO{!TEm$a=${327W{`06zi%B%b1dr+EO-CzyqrvoKIEFzegKz)BYk z0Dk5GY#RjNVGs*HJb_3Cz)6UnAC=(C0!ASw0Ic~FM2ZF^Swn^INa25C?Hl&`53H@g z`IkY_pRMUr2>~pBS+T)bj{Ovzi4!X`mSFJm6Y$SrEVKU}hc%5zw4_p58kbRD);$=$ zjQoNimQ+8Y8Qvd={CiR%7T=PzoY`MYEQ@_JfeRqhoj_2q6Zor7E+81N{_2<u$YmuI z%QCjoF%||*2aNxMI|HR5dC;A{Rua6NVHTQ>p#}h0HGG9a_=<0qFXs>g;JSWPFCaod z@i$nT6|WZ&K-!298#>WEfZ<1?u&kQ<6Dhg`*7V0|xn;l*dk04_CBV<`>jxAt`L!nB zGxt{^Oz{*iIU+^L#!4OpF~ie*jVYc~Aa|@K(E|+N;;bJi0Py)&q#3-7L0hhLe8n#v zz5$b(SOY8;3Pq|yp{j5w6om$^2;d5g5>(;ZEO37roBhUyz<gyv0j$gH02=}jN1?u* zfH+D8H~~7q0#yM%R9I_+uj}P2KwrKBd@LTg8ekqcRahsW2MOq7T~z=#;DEkm9)QNW z{%`FgSiZ3Q)Bk#^0=}RCU%rQ_iq?iKCjux33;2}NS7!mQP+*P*gF(^Dn5!%lDl5Mt ziA);N6X-j*3J7rM+W|%)kt#^AC-@r;1q=wm%g?}+?=&cs^@gyH6*RaC(C0tVU~nL7 zztUi8tkL67d_c|ti@(qS-+$EyBn|beT^I}v|5YCh4p;pfS+!qfp-2?^S6QHaSR<vM z{X?RE&jIT9c%dl7Z@vID<ZrQ|RFS{>0)qp8zt{!hU~LmW`2vHh{H6~Eq-q5pgN6qt zqcqkgY()wNb|@8KbD{$8{qjo+g0NmSFw0Taj%ch8Y&Dv?XqYZiPhTGmbPE)zud1d7 z*9Dpwj>W*BFg-XN0KWf67S;v>?70|kBEgp)Kn6opQ80vxJ`9F1Kw<PySQUK(6$1p& i`*0PMJ{owPSi2*gfu}K++Ykm<Lm@%Z(gx;O(0>7aQ~Zwr diff --git a/spec/fixtures/rag/doc_with_metadata.txt b/spec/fixtures/rag/doc_with_metadata.txt deleted file mode 100644 index c8132f38..00000000 --- a/spec/fixtures/rag/doc_with_metadata.txt +++ /dev/null @@ -1,52 +0,0 @@ -No metadata yet, first chunk ’ こんにちは -Jane’s -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 ’ 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -Jane’s - -[[metadata Sam's story]] -Once upon a time, in a land far, far away (or maybe just down the street, who knows?), there lived a brilliant AI developer named Sam. Sam had a vision, a dream, nay, a burning desire to create the most impressive discourse AI the world had ever seen. Armed with a keyboard, an endless supply of coffee, and a mildly concerning lack of sleep, Sam embarked on this epic quest. - -Day and night, Sam toiled away, crafting lines of code that would make even the most seasoned programmers weep with joy. The AI slowly took shape, like a majestic, digital phoenix rising from the ashes of Sam’s social life. It was a thing of beauty, a marvel of modern technology, and it had the uncanny ability to generate conversations about anything from the meaning of life to the best way to make a grilled cheese sandwich. - -As the project neared completion, Sam realized that there was one crucial element missing: a spec doc. And not just any spec doc, but a spec doc filled with glorious, meaningless dummy text. Because let’s face it, nothing screams “professional” quite like a wall of lorem ipsum. - -So, Sam set out to create the most impressive dummy text the world had ever seen. It would be a masterpiece, a symphony of nonsensical words that would leave readers in awe of Sam’s ability to fill space with utter gibberish. And thus, the dummy text was born. - -[[METADATE]] - -It was a sight to behold, a tapestry of random words woven together in a way that almost made sense, but not quite. It spoke of ancient mysteries, like why hotdogs come in packs of ten, while hotdog buns come in packs of eight. It pondered the great questions of our time, like whether or not pineapple belongs on pizza (spoiler alert: it does). And it even dared to explore the darkest corners of Sam’s imagination, like the idea of a world without caffeine. - -In the end, Sam’s discourse AI was a resounding success. It could carry on conversations with humans for hours on end, discussing everything from the latest trends in fashion to the intricacies of quantum physics. And whenever anyone asked about the impressive spec doc, Sam would just smile and nod, knowing full well that the real magic lay in the glorious dummy text that started it all. - -And so, dear reader, if you ever find yourself in need of some impressive dummy text for your own project, just remember the tale of Sam and their magnificent discourse AI. Because sometimes, all it takes is a little nonsense to make the world a whole lot more interesting. - -[[metadata Jane's story]] - -Ah, Jane. The name alone conjures up images of brilliance, wit, and a certain je ne sais quoi that can only be described as “Janeesque.” And so, it comes as no surprise that our dear Jane found herself embarking on a journey of epic proportions: the creation of a discourse AI that would put all other discourse AIs to shame. - -With a twinkle in her eye and a spring in her step, Jane set forth on this noble quest. She gathered her trusty companions: a laptop, a never-ending supply of tea, and a collection of obscure reference books that would make even the most studious librarian green with envy. Armed with these tools, Jane began her work. - -As she typed away at her keyboard, Jane couldn’t help but feel a sense of excitement bubbling up inside her. This was no ordinary project; this was a chance to create something truly extraordinary. She poured her heart and soul into every line of code, crafting algorithms that would make even the most advanced AI systems [[look]] like mere calculators. - -But Jane knew that a discourse AI was only as good as its training data. And so, she scoured the internet, collecting the most fascinating, hilarious, and downright bizarre conversations she could find. From heated debates about the proper way to make a cup of tea to in-depth discussions on the mating habits of the rare Peruvian flying squirrel, Jane left no stone unturned. - -As the weeks turned into months, Jane’s creation began to take shape. It was a thing of beauty, a masterpiece of artificial intelligence that could engage in witty banter, offer sage advice, and even tell the occasional joke (though its sense of humor was admittedly a bit on the quirky side). Jane beamed with pride as she watched her AI converse with humans, marveling at its ability to understand and respond to even the most complex of queries. - -But there was one final hurdle to overcome: the dreaded spec doc. Jane knew that no self-respecting AI could be unleashed upon the world without a proper set of specifications. And so, she set about crafting the most magnificent dummy text the world had ever seen. - -It was a masterpiece of nonsense, a symphony of absurdity that would leave even the most seasoned tech writer scratching their head in confusion. From descriptions of the AI’s ability to recite Shakespearean sonnets in binary code to detailed explanations of its built-in “tea break” feature, Jane’s dummy text was a work of art. - -And so, with a flourish of her keyboard and a triumphant grin, Jane unleashed her creation upon the world. The response was immediate and overwhelming. People from all walks of life flocked to converse with Jane’s AI, marveling at its intelligence, its charm, and its uncanny ability to make even the most mundane of topics seem fascinating. - -In the end, Jane’s discourse AI became the stuff of legend, a shining example of what can be achieved when brilliance, determination, and a healthy dose of eccentricity come together. And as for Jane herself? Well, let’s just say that she’s already hard at work on her next project: a robot that can make the perfect cup of tea. But that, dear reader, is a story for another day. -[[metadata blank1]] -[[metadata blank2]] -abc diff --git a/spec/fixtures/rag/parsed_doc_with_metadata.txt b/spec/fixtures/rag/parsed_doc_with_metadata.txt deleted file mode 100644 index 331a51d0..00000000 --- a/spec/fixtures/rag/parsed_doc_with_metadata.txt +++ /dev/null @@ -1,122 +0,0 @@ -metadata: -number: 1 -No metadata yet, first chunk ’ こんにちは -Jane’s -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -1 2 ’ 4 5 6 7 8 9 10 - -metadata: -number: 2 -’ 4 5 6 7 8 9 10 -1 2 3 4 5 6 7 8 9 10 -Jane’s - -metadata: Sam's story -number: 3 -Once upon a time, in a land far, far away (or maybe just down the street, who knows?), there lived a brilliant AI developer named Sam. Sam had a vision, a dream, nay, a burning desire to create the most impressive discourse AI the world had ever seen. Armed with a keyboard, an endless supply of coffee, and a mildly concerning lack of sleep, Sam embarked on this epic quest. - -metadata: Sam's story -number: 4 -sam embarked on this epic quest. - -Day and night, Sam toiled away, crafting lines of code that would make even the most seasoned programmers weep with joy. The AI slowly took shape, like a majestic, digital phoenix rising from the ashes of Sam’s social life. It was a thing of beauty, a marvel of modern technology, and it had the uncanny ability to generate conversations about anything from the meaning of life to the best way to make a grilled cheese sandwich. - -metadata: Sam's story -number: 5 -to make a grilled cheese sandwich. - -As the project neared completion, Sam realized that there was one crucial element missing: a spec doc. And not just any spec doc, but a spec doc filled with glorious, meaningless dummy text. Because let’s face it, nothing screams “professional” quite like a wall of lorem ipsum. - -metadata: Sam's story -number: 6 -a wall of lorem ipsum. - -So, Sam set out to create the most impressive dummy text the world had ever seen. It would be a masterpiece, a symphony of nonsensical words that would leave readers in awe of Sam’s ability to fill space with utter gibberish. And thus, the dummy text was born. - -[[METADATE]] - -metadata: Sam's story -number: 7 -born. [ [ metadate ] ] - -.It was a sight to behold, a tapestry of random words woven together in a way that almost made sense, but not quite. It spoke of ancient mysteries, like why hotdogs come in packs of ten, while hotdog buns come in packs of eight. It pondered the great questions of our time, like whether or not pineapple belongs on pizza (spoiler alert: it does) - -metadata: Sam's story -number: 8 -( spoiler alert : it does ). - - And it even dared to explore the darkest corners of Sam’s imagination, like the idea of a world without caffeine. - -metadata: Sam's story -number: 9 -of a world without caffeine. - -In the end, Sam’s discourse AI was a resounding success. It could carry on conversations with humans for hours on end, discussing everything from the latest trends in fashion to the intricacies of quantum physics. And whenever anyone asked about the impressive spec doc, Sam would just smile and nod, knowing full well that the real magic lay in the glorious dummy text that started it all. - -metadata: Sam's story -number: 10 -glorious dummy text that started it all. - -And so, dear reader, if you ever find yourself in need of some impressive dummy text for your own project, just remember the tale of Sam and their magnificent discourse AI. Because sometimes, all it takes is a little nonsense to make the world a whole lot more interesting. - -metadata: Jane's story -number: 11 -Ah, Jane. The name alone conjures up images of brilliance, wit, and a certain je ne sais quoi that can only be described as “Janeesque.” And so, it comes as no surprise that our dear Jane found herself embarking on a journey of epic proportions: the creation of a discourse AI that would put all other discourse AIs to shame. - -metadata: Jane's story -number: 12 -all other discourse ais to shame. - -With a twinkle in her eye and a spring in her step, Jane set forth on this noble quest. She gathered her trusty companions: a laptop, a never-ending supply of tea, and a collection of obscure reference books that would make even the most studious librarian green with envy. Armed with these tools, Jane began her work. - -metadata: Jane's story -number: 13 -these tools, jane began her work. - -As she typed away at her keyboard, Jane couldn’t help but feel a sense of excitement bubbling up inside her. This was no ordinary project; this was a chance to create something truly extraordinary. She poured her heart and soul into every line of code, crafting algorithms that would make even the most advanced AI systems [[look]] like mere calculators. - -metadata: Jane's story -number: 14 -] ] like mere calculators. - -But Jane knew that a discourse AI was only as good as its training data. And so, she scoured the internet, collecting the most fascinating, hilarious, and downright bizarre conversations she could find. From heated debates about the proper way to make a cup of tea to in-depth discussions on the mating habits of the rare Peruvian flying squirrel, Jane left no stone unturned. - -metadata: Jane's story -number: 15 -jane left no stone unturned. - -As the weeks turned into months, Jane’s creation began to take shape. It was a thing of beauty, a masterpiece of artificial intelligence that could engage in witty banter, offer sage advice, and even tell the occasional joke (though its sense of humor was admittedly a bit on the quirky side). Jane beamed with pride as she watched her AI converse with humans, marveling at its ability to understand and respond to even the most complex of queries. - -metadata: Jane's story -number: 16 -even the most complex of queries. - -But there was one final hurdle to overcome: the dreaded spec doc. Jane knew that no self-respecting AI could be unleashed upon the world without a proper set of specifications. And so, she set about crafting the most magnificent dummy text the world had ever seen. - -metadata: Jane's story -number: 17 -dummy text the world had ever seen. - -It was a masterpiece of nonsense, a symphony of absurdity that would leave even the most seasoned tech writer scratching their head in confusion. From descriptions of the AI’s ability to recite Shakespearean sonnets in binary code to detailed explanations of its built-in “tea break” feature, Jane’s dummy text was a work of art. - -metadata: Jane's story -number: 18 -dummy text was a work of art. - -And so, with a flourish of her keyboard and a triumphant grin, Jane unleashed her creation upon the world. The response was immediate and overwhelming. People from all walks of life flocked to converse with Jane’s AI, marveling at its intelligence, its charm, and its uncanny ability to make even the most mundane of topics seem fascinating. - -metadata: Jane's story -number: 19 -the most mundane of topics seem fascinating. - -In the end, Jane’s discourse AI became the stuff of legend, a shining example of what can be achieved when brilliance, determination, and a healthy dose of eccentricity come together. And as for Jane herself? Well, let’s just say that she’s already hard at work on her next project: a robot that can make the perfect cup of tea. But that, dear reader, is a story for another day. - -metadata: blank2 -number: 20 -abc \ No newline at end of file diff --git a/spec/fixtures/search_meta/search.json b/spec/fixtures/search_meta/search.json deleted file mode 100644 index b02212a1..00000000 --- a/spec/fixtures/search_meta/search.json +++ /dev/null @@ -1 +0,0 @@ -{"posts":[{"id":218354,"name":"Robin Ward","username":"eviltrout","avatar_template":"/user_avatar/meta.discourse.org/eviltrout/{size}/5275_2.png","created_at":"2016-08-24T20:48:02.587Z","like_count":15,"blurb":"Automated <span class=\"search-highlight\">tests</span> are a great way to protect your code against future regressions. Many people are familiar with how to do this in our Rails codebase with http://rspec.info/ rspec , but the Javascript s...","post_number":1,"topic_title_headline":"Write acceptance <span class=\"search-highlight\">tests</span> and component <span class=\"search-highlight\">tests</span> for Ember code in Discourse","topic_id":49167},{"id":138484,"name":"Robin Ward","username":"eviltrout","avatar_template":"/user_avatar/meta.discourse.org/eviltrout/{size}/5275_2.png","created_at":"2015-08-27T21:32:26.407Z","like_count":29,"blurb":"Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-part-5-add-an-admin-interface/31761 Developing Discourse Plugins - Part 5 - Add an admin interface Did you know that Discou...","post_number":1,"topic_title_headline":"Developing Discourse Plugins - Part 6 - Add acceptance <span class=\"search-highlight\">tests</span>","topic_id":32619},{"id":1381521,"name":"Alan Tan","username":"tgxworld","avatar_template":"/user_avatar/meta.discourse.org/tgxworld/{size}/106117_2.png","created_at":"2023-10-24T23:13:37.118Z","like_count":16,"blurb":"Writing automated <span class=\"search-highlight\">tests</span> for themes is an important part of the theme development process which can help ensure that the features being introduced by a theme continues to work well overtime with core D...","post_number":1,"topic_title_headline":"End-to-end system <span class=\"search-highlight\">testing</span> for themes and theme components","topic_id":281579},{"id":311252,"name":"David Taylor","username":"david","avatar_template":"/user_avatar/meta.discourse.org/david/{size}/157490_2.png","created_at":"2017-07-26T14:09:58.126Z","like_count":17,"blurb":"Discourse has extensive frontend <span class=\"search-highlight\">tests</span> for core, plugins and themes. Once you have a functioning local development environment, those <span class=\"search-highlight\">tests</span> can be run locally in a number of different ways. Running te...","post_number":1,"topic_title_headline":"How to run Discourse core, plugin and theme QUnit <span class=\"search-highlight\">test</span> suites","topic_id":66857},{"id":59431,"name":"Erlend Sogge Heggen","username":"erlend_sh","avatar_template":"/user_avatar/meta.discourse.org/erlend_sh/{size}/119475_2.png","created_at":"2014-07-03T11:45:09.463Z","like_count":5,"blurb":"...view=1 to an URL, but the emulator has the added benefit of letting you select the screen profile of a specific device. I also <span class=\"search-highlight\">tested</span> all of the most popular online screen emulators, but unfortunately...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Test</span> Discourse in mobile screen emulator","topic_id":17155},{"id":1419473,"name":"","username":"ToddZ","avatar_template":"/user_avatar/meta.discourse.org/toddz/{size}/328350_2.png","created_at":"2023-12-12T10:51:08.401Z","like_count":2,"blurb":"...amount of time troubleshooting inbound email because Discourse was rejecting every reply-by-email from my fake users. It had worked fine when I first <span class=\"search-highlight\">tested</span> several weeks ago… I finally realized that ...","post_number":1,"topic_title_headline":"Tip: when <span class=\"search-highlight\">testing</span> inbound email with fake user accounts…","topic_id":288363},{"id":722424,"name":"Falco","username":"Falco","avatar_template":"/user_avatar/meta.discourse.org/falco/{size}/179432_2.png","created_at":"2020-03-26T21:31:38.463Z","like_count":17,"blurb":"Continuing the discussion from https://meta.discourse.org/t/user-api-keys-specification/48536 User API keys specification : I created a small utility script in order to <span class=\"search-highlight\">test</span> User API keys locally. Fir...","post_number":1,"topic_title_headline":"Generate User API Keys for <span class=\"search-highlight\">testing</span>","topic_id":145744},{"id":266441,"name":"Andrew Waugh","username":"JagWaugh","avatar_template":"/user_avatar/meta.discourse.org/jagwaugh/{size}/69335_2.png","created_at":"2017-03-03T13:34:08.009Z","like_count":19,"blurb":"Regardless of if you're a moderator or an admin, you will no doubt at some time think about making some change to your live site and wonder if this will bring shame on you, and/or cause yourself an en...","post_number":1,"topic_title_headline":"Build a sandbox to <span class=\"search-highlight\">test</span> changes before making them live","topic_id":58298},{"id":582008,"name":"","username":"Wurzelseppi","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/w/eada6e/{size}.png","created_at":"2019-05-21T10:42:51.099Z","like_count":0,"blurb":"Hi guys, just wanted to migrate from 2.3.0beta9 to stable release and got this error: What can I do here ? Caused by: PG::UndefinedColumn: ERROR: column \"email_private_messages\" of relation \"user_opti...","post_number":1,"topic_title_headline":"Migrate from <span class=\"search-highlight\">tests</span>-passed to stable","topic_id":118296},{"id":1421414,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2023-12-14T17:11:26.575Z","like_count":1,"blurb":"Restarting the server, restarting the container, console rebuilding doesn't help. Container is up as I can ./launcher enter app Got a bunch of these in logs, ideas on how to investigate redis failure?...","post_number":1,"topic_title_headline":"502 Bad Gateway after online rebuild of <span class=\"search-highlight\">tests</span>-passed Production just now","topic_id":288705},{"id":1204506,"name":"Coin-coin le Canapin","username":"Canapin","avatar_template":"/user_avatar/meta.discourse.org/canapin/{size}/119591_2.png","created_at":"2022-12-02T20:49:55.867Z","like_count":1,"blurb":"Hi! I want to add support of /shorts/ Youtube link. My modification of the YoutubeOnebox class works, but it is required that I add a <span class=\"search-highlight\">test</span> in https://github.com/discourse/discourse/blob/493d437e79f88a...","post_number":1,"topic_title_headline":"Trouble on adding a simple unit <span class=\"search-highlight\">test</span> for Youtube oneboxing","topic_id":247546},{"id":1339808,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2023-08-07T10:05:22.444Z","like_count":2,"blurb":"I have a strange issue with QUnit. This <span class=\"search-highlight\">test</span> is extremely simple and should be straightforward … but … A plugin setting is changing from those I have set up. https://github.com/paviliondev/discourse-l...","post_number":1,"topic_title_headline":"Strange QUnit behaviour?: <span class=\"search-highlight\">test</span> failing because setting value doesn’t survive","topic_id":274165},{"id":1211823,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2022-12-16T18:32:29.231Z","like_count":1,"blurb":"...presenting formatted location on User Card by merefield · Pull Request #73 · paviliondev/discourse-locations · GitHub I'm attempting to cover the change with a new Front End <span class=\"search-highlight\">test</span>. But the <span class=\"search-highlight\">test</span> fails t...","post_number":1,"topic_title_headline":"Is it possible to override the Site object with own fixture during Front End <span class=\"search-highlight\">tests</span> of a Plugin?","topic_id":249167},{"id":1408389,"name":"Pierre Romera","username":"pirhoo","avatar_template":"/user_avatar/meta.discourse.org/pirhoo/{size}/120058_2.png","created_at":"2023-11-22T18:46:46.469Z","like_count":5,"blurb":"...m setting up a new plugin based on the https://github.com/discourse/discourse-plugin-skeleton/tree/main/assets skeleton you provided which already helped me a lot. I am now writing <span class=\"search-highlight\">tests</span>, both for the...","post_number":1,"topic_title_headline":"Acceptance <span class=\"search-highlight\">tests</span> failing on Github Actions","topic_id":286355},{"id":443129,"name":"JK Baseer","username":"JKBaseer","avatar_template":"/user_avatar/meta.discourse.org/jkbaseer/{size}/80471_2.png","created_at":"2018-07-01T17:12:48.446Z","like_count":0,"blurb":"...but still could myself. Background: I installed discourse using digitalocean oneclick installer. The website is running under http://forum.example.org forum.example.org without any problem except the ...","post_number":1,"topic_title_headline":"There was a problem sending the <span class=\"search-highlight\">test</span> email","topic_id":91312},{"id":1412219,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2023-11-29T23:18:11.815Z","like_count":1,"blurb":"I'm trying to create a foreign key relationship with the Topics table. The problem is it is failing in github workflow <span class=\"search-highlight\">test</span> environment during <span class=\"search-highlight\">tests</span> for the strangest reason, it is trying to access a ...","post_number":1,"topic_title_headline":"Strange migration error in <span class=\"search-highlight\">tests</span> during GH workflow","topic_id":287022},{"id":1160803,"name":"Bryan Joseph","username":"Bryan_Joseph","avatar_template":"/user_avatar/meta.discourse.org/bryan_joseph/{size}/273248_2.png","created_at":"2022-09-07T20:22:11.191Z","like_count":1,"blurb":"...SETTINGS ==================== DISCOURSE_HOSTNAME=url SMTP_ADDRESS=smtp.mailgun.org DEVELOPER_EMAILS=REDACTED SMTP_PASSWORD=REDACTED SMTP_PORT=2525 SMTP_USER_NAME=url LETSENCRYPT_ACCOUNT_EMAIL=REDACTED...","post_number":1,"topic_title_headline":"Smtp doctor <span class=\"search-highlight\">test</span> using port 465 even though its configured to use 2525","topic_id":238372},{"id":725228,"name":"james.network","username":"sunjam","avatar_template":"/user_avatar/meta.discourse.org/sunjam/{size}/175682_2.png","created_at":"2020-03-31T18:51:44.216Z","like_count":0,"blurb":"...Redis or updating it; it hasn't really been touched in the last 8+ months. I have not personally dealt with Redis before, but our <span class=\"search-highlight\">Tests</span>-Pass Discourse instance was setup using https://hub.docker.com/r...","post_number":1,"topic_title_headline":"Sidekiq not running. Sidekiq heartbeat <span class=\"search-highlight\">test</span> failed, restarting","topic_id":146326},{"id":1240758,"name":"Jay Pfaffman","username":"pfaffman","avatar_template":"/user_avatar/meta.discourse.org/pfaffman/{size}/120154_2.png","created_at":"2023-02-16T22:12:10.456Z","like_count":4,"blurb":"I see that the discourse-plugin-skeleton now has this: uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1 so we don't have to keep updating stuff. But I have a plugin that requires the ...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Tests</span> for plugin that requires a plugin","topic_id":255406},{"id":1197679,"name":"","username":"SilK","avatar_template":"/user_avatar/meta.discourse.org/silk/{size}/268124_2.png","created_at":"2022-11-18T17:03:46.056Z","like_count":0,"blurb":"...a new dev environment for working on plugins. Discourse is up to date with the main branch. I need to restart Ember in order to <span class=\"search-highlight\">test</span> changes made to the front end. This includes changes to Handlebars,...","post_number":1,"topic_title_headline":"Need to restart Ember in order to <span class=\"search-highlight\">test</span> front-end changes","topic_id":246069},{"id":1103913,"name":"Banibrata Dutta","username":"bdutta","avatar_template":"/user_avatar/meta.discourse.org/bdutta/{size}/259973_2.png","created_at":"2022-05-15T18:02:33.290Z","like_count":0,"blurb":"...only in a captive host-only testbed, so wondering if there is any local network SMTP daemon / service that I could start to complete the <span class=\"search-highlight\">testing</span> ? I'm happy with 100% command line mail client and serv...","post_number":1,"topic_title_headline":"Bitnami Discourse VM on Virtualbox + SMTP mail-server for <span class=\"search-highlight\">testing</span>","topic_id":227090},{"id":722608,"name":"Lona Lee","username":"Lona_Lee","avatar_template":"/user_avatar/meta.discourse.org/lona_lee/{size}/169072_2.png","created_at":"2020-03-27T07:14:10.239Z","like_count":1,"blurb":"Hello. I'm trying to get email setup working on my discourse instance. Done set-up properly and looks fine(no errors), so sent <span class=\"search-highlight\">test</span> emails. (logs confirmed : \"Admin\" - \"Emails\" - \"Sent\") However, I ha...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Test</span> emails sent but","topic_id":145781},{"id":679950,"name":"Oleg Bovykin","username":"arrowcircle","avatar_template":"/user_avatar/meta.discourse.org/arrowcircle/{size}/100035_2.png","created_at":"2020-01-01T11:20:33.618Z","like_count":1,"blurb":"Hi! I found strange error in my admin page, that sidekiq is not running. I opened logs and found hundreds errors like: /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/logster-2.5.1/lib/logster/logger...","post_number":1,"topic_title_headline":"Sidekiq heartbeat <span class=\"search-highlight\">test</span> failed, restarting","topic_id":137496},{"id":1033333,"name":"М. М.","username":"М_М","avatar_template":"/user_avatar/meta.discourse.org/м_м/{size}/243710_2.png","created_at":"2021-12-20T13:52:10.488Z","like_count":0,"blurb":"...user, the logs say like this Job exception: could not get 3xx (421: 421 Domain sandbox410fe5c7bb85483c941c05b4ec5f3495.mailgun.org is not allowed to send: Sandbox subdomains are for <span class=\"search-highlight\">test</span> purposes only...","post_number":1,"topic_title_headline":"MailGun & Discourse: Sandbox subdomains are for <span class=\"search-highlight\">test</span> purposes only. Please add your own domain","topic_id":212684},{"id":498559,"name":"","username":"desrocchi","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/d/eb9ed0/{size}.png","created_at":"2018-11-14T15:20:59.607Z","like_count":0,"blurb":"Is there a way for me to see or <span class=\"search-highlight\">test</span> the admin options in the demo area? I am just a moderator on the platform we use but I would like to see which options could be of use without having to install th...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Test</span> admin features without having to install Discourse","topic_id":102035},{"id":596557,"name":"Flaviu","username":"UnivacTwo","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/u/df705f/{size}.png","created_at":"2019-06-25T17:12:34.710Z","like_count":0,"blurb":"Let's encrypt has a limit of how many certificates can be generated in a week for the same domain. Unfortunately we reach this limit and we cannot generate a new certificate this week. We did a backup...","post_number":1,"topic_title_headline":"Install discourse with a staging (<span class=\"search-highlight\">test</span>) ssl certificate","topic_id":121299},{"id":583541,"name":"mark78","username":"Mark_Schmucker","avatar_template":"/user_avatar/meta.discourse.org/mark_schmucker/{size}/124810_2.png","created_at":"2019-05-24T00:14:35.895Z","like_count":0,"blurb":"Should I be able to run any Badge Query in https://meta.discourse.org/t/32566 Data Explorer ? I want to create a custom Badge Query using \"Appreciated\" as a starting point. I type the Appreciated quer...","post_number":1,"topic_title_headline":"Problem <span class=\"search-highlight\">testing</span> Badge Query from Data Explorer","topic_id":118568},{"id":569209,"name":"Penar Musaraj","username":"pmusaraj","avatar_template":"/user_avatar/meta.discourse.org/pmusaraj/{size}/119489_2.png","created_at":"2019-04-25T01:24:56.741Z","like_count":26,"blurb":"...device and installing the app via TestFlight: https://testflight.apple.com/join/NkdBQgmg testflight.apple.com https://testflight.apple.com/join/NkdBQgmg TestFlight - Apple Using TestFlight is a great ...","post_number":1,"topic_title_headline":"New iOS mobile app beta available for <span class=\"search-highlight\">testing</span>","topic_id":115912},{"id":1090520,"name":"Mac玩儿法","username":"waerfa","avatar_template":"/user_avatar/meta.discourse.org/waerfa/{size}/216044_2.png","created_at":"2022-04-17T21:46:04.755Z","like_count":0,"blurb":"...rebuild the container: git pull ./launcher rebuild app I got the fatal error which shows: FAILED -------------------- Pups::ExecError: cd /var/www/discourse & & git fetch --depth 1 origin <span class=\"search-highlight\">tests</span>-passed...","post_number":1,"topic_title_headline":"502 Bad Gateway after trying to rebuild <span class=\"search-highlight\">test</span>-passed branch","topic_id":224560},{"id":860593,"name":"james.network","username":"sunjam","avatar_template":"/user_avatar/meta.discourse.org/sunjam/{size}/175682_2.png","created_at":"2020-12-11T18:44:18.021Z","like_count":1,"blurb":"Continuing the discussion from https://meta.discourse.org/t/postgresql-13-update/172563/27 PostgreSQL 13 update : Run into trouble while updating 2.7.0beta1 <span class=\"search-highlight\">Tests</span>-Pass in order to remove some troubles...","post_number":1,"topic_title_headline":"Forum offline due to failed rebuilds on <span class=\"search-highlight\">Tests</span>-Pass","topic_id":173019},{"id":960683,"name":"","username":"daniyal","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/d/58f4c7/{size}.png","created_at":"2021-07-08T19:58:55.634Z","like_count":3,"blurb":"...which we want to experiment. An example would be to experiment different styles of topic list view. For this we are using Google Optimize A/B <span class=\"search-highlight\">testing</span>. Currently we plan to show theme without changes t...","post_number":1,"topic_title_headline":"[A/B <span class=\"search-highlight\">Testing</span>] Changing parent CSS class based on experiment variable","topic_id":196501},{"id":1389188,"name":"Angus McLeod","username":"angus","avatar_template":"/user_avatar/meta.discourse.org/angus/{size}/341715_2.png","created_at":"2023-10-20T03:44:49.737Z","like_count":7,"blurb":"I've been looking at the performance of the https://meta.discourse.org/t/activitypub-plugin/266794 ActivityPub plugin recently and considering the best ways to reliably <span class=\"search-highlight\">test</span>, and prove, performance fo...","post_number":1,"topic_title_headline":"Code-level performance <span class=\"search-highlight\">testing</span>","topic_id":282856},{"id":1329017,"name":"","username":"dodibi","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/d/9fc348/{size}.png","created_at":"2023-07-19T12:45:57.446Z","like_count":0,"blurb":"Hello everyone! I'm currently facing some challenges while configuring my local environment to run discourse <span class=\"search-highlight\">tests</span> in a docker container. My main objective is to run the core <span class=\"search-highlight\">tests</span> with plugins attach...","post_number":1,"topic_title_headline":"Running core <span class=\"search-highlight\">tests</span> in docker environment","topic_id":272112},{"id":421687,"name":"Jay Pfaffman","username":"pfaffman","avatar_template":"/user_avatar/meta.discourse.org/pfaffman/{size}/120154_2.png","created_at":"2018-05-11T22:03:23.232Z","like_count":0,"blurb":"Is there a way to initiate an email <span class=\"search-highlight\">test</span> from the Rails console? For a zillion reasons I would love to be able to send a <span class=\"search-highlight\">test</span> email without having to create an account. I've looked in config/routes.rb...","post_number":1,"topic_title_headline":"Email <span class=\"search-highlight\">test</span> from the console?","topic_id":87295},{"id":272436,"name":"David Taylor","username":"david","avatar_template":"/user_avatar/meta.discourse.org/david/{size}/157490_2.png","created_at":"2017-03-20T20:29:06.608Z","like_count":1,"blurb":"It is my understanding that running rake qunit:<span class=\"search-highlight\">test</span> should run all of the qunit <span class=\"search-highlight\">tests</span> in Discourse, including those for any installed plugins. However, when I run the task in the docker development en...","post_number":1,"topic_title_headline":"Plugin QUnit <span class=\"search-highlight\">tests</span> are not running as part of rake qunit:<span class=\"search-highlight\">test</span>","topic_id":59577},{"id":899205,"name":"","username":"JQ331","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/j/41988e/{size}.png","created_at":"2021-03-03T18:09:29.452Z","like_count":6,"blurb":"I came across this https://blog.codinghorror.com/low-fi-usability-testing/ excellent article on how to do low-fi usability <span class=\"search-highlight\">testing</span> by @codinghorror . Usability <span class=\"search-highlight\">testing</span> (and user <span class=\"search-highlight\">testing</span> in general) is...","post_number":1,"topic_title_headline":"How does the Discourse team do usability <span class=\"search-highlight\">testing</span>?","topic_id":181856},{"id":530438,"name":"","username":"kleinfreund","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/k/a6a055/{size}.png","created_at":"2019-02-01T09:52:27.150Z","like_count":1,"blurb":"One can write <span class=\"search-highlight\">tests</span> for the backend of a plugin. For example, I created the following file in my plugin directory: spec/lib/route_store_spec.rb : require 'rails_helper' describe MyPlugin::RouteStore d...","post_number":1,"topic_title_headline":"Advice on writing Ruby <span class=\"search-highlight\">tests</span> for plugins","topic_id":108110},{"id":260691,"name":"Rimian Perkins","username":"rimian","avatar_template":"/user_avatar/meta.discourse.org/rimian/{size}/120658_2.png","created_at":"2017-02-13T02:48:10.177Z","like_count":0,"blurb":"What's the best way to (QUnit) assert an element on the page has some content in it? This passes: ok($.trim($('.foo').text()) == 'bar', 'content bar renders on page'); But isn't very practical. Is the...","post_number":1,"topic_title_headline":"Acceptance <span class=\"search-highlight\">test</span> content is present on page","topic_id":57292},{"id":1432567,"name":"Ayke","username":"rrit","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/r/b5ac83/{size}.png","created_at":"2024-01-09T16:53:57.916Z","like_count":0,"blurb":"Right now Discourse on meta.discourse.org serves the mobile-view instead of the crawler-view to the https://search.google.com/test/rich-results Google Rich Results <span class=\"search-highlight\">Test</span> . As there is no Schema Markup ...","post_number":1,"topic_title_headline":"Google Search Console/Schema Markup <span class=\"search-highlight\">test</span> tool “Google Rich Results <span class=\"search-highlight\">Test</span>”: mobile-view instead of crawler-view","topic_id":291039},{"id":1305996,"name":"Larry Diehl","username":"larrytheliquid","avatar_template":"/user_avatar/meta.discourse.org/larrytheliquid/{size}/310576_2.png","created_at":"2023-06-08T22:35:51.914Z","like_count":1,"blurb":"Hi Discourse Community! :slight_smile: I've been working on tech ( https://colimit.io Colimit ) that helps people apply https://en.wikipedia.org/wiki/Model-based_testing Model-based <span class=\"search-highlight\">testing</span> to <span class=\"search-highlight\">test</span> th...","post_number":1,"topic_title_headline":"Experiments with Model-Based <span class=\"search-highlight\">Testing</span>","topic_id":267737},{"id":1135474,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2022-07-20T08:48:28.868Z","like_count":0,"blurb":"...my case running: rake \"plugin:qunit[discourse-multilingual]\" with a branch installed. I'm declaring a function in my initializer (i'm extending I18n ) The <span class=\"search-highlight\">tests</span> sometimes (25%?) seem to run before the...","post_number":1,"topic_title_headline":"Qunit <span class=\"search-highlight\">tests</span> not deterministic in Plugin?","topic_id":233389},{"id":672801,"name":"Jay Pfaffman","username":"pfaffman","avatar_template":"/user_avatar/meta.discourse.org/pfaffman/{size}/120154_2.png","created_at":"2019-12-12T21:01:31.303Z","like_count":0,"blurb":"Thanks, @Mittineague ! After a while, that made sense. I even wrote a spec, but even before I added my spec (and when I reverted to before I added any code), specs fail because: An error occurred whil...","post_number":1,"topic_title_headline":"Issues migrating <span class=\"search-highlight\">test</span> database","topic_id":135876},{"id":871105,"name":"","username":"Alteras","avatar_template":"/user_avatar/meta.discourse.org/alteras/{size}/179824_2.png","created_at":"2021-01-07T19:56:05.071Z","like_count":4,"blurb":"Hello! I'm currently working on a Markdown Extension/plugin that adds quite a number of BBCode tags, and I am looking to write QUnit Acceptance <span class=\"search-highlight\">tests</span> for them (I got really tired of constantly checkin...","post_number":1,"topic_title_headline":"Acceptance <span class=\"search-highlight\">Test</span> for Markdown Extension?","topic_id":175413},{"id":747613,"name":"","username":"xrav3nz","avatar_template":"/user_avatar/meta.discourse.org/xrav3nz/{size}/76894_2.png","created_at":"2020-05-08T04:27:56.920Z","like_count":8,"blurb":"...development - #2 by taylorthurlow - A May Of WTFs - Ruby on Rails Discussions Not sure if we have explored this before, but Rails can automatically maintain <span class=\"search-highlight\">test</span> databse schema with ActiveRecord::Migr...","post_number":1,"topic_title_headline":"Auto migrate <span class=\"search-highlight\">test</span> database schema","topic_id":150786},{"id":583961,"name":"Kim Miller","username":"kimardenmiller","avatar_template":"/user_avatar/meta.discourse.org/kimardenmiller/{size}/119631_2.png","created_at":"2019-05-24T22:21:24.996Z","like_count":3,"blurb":"Adding some polls API endpoints for PR to discourse_api, which work fine. Now I'm trying to understand how to create <span class=\"search-highlight\">tests</span> before submitting the PR, e.g.: require 'spec_helper' describe DiscourseApi::...","post_number":1,"topic_title_headline":"Building <span class=\"search-highlight\">Tests</span> for New discourse_api Endpoints","topic_id":118639},{"id":621707,"name":"Andrew Lank","username":"alank","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/a/c89c15/{size}.png","created_at":"2019-08-17T02:13:06.679Z","like_count":1,"blurb":"In my development and <span class=\"search-highlight\">testing</span> I'm running a Discourse instance (Docker Discourse from Bitnami) and it fulfills most of my API <span class=\"search-highlight\">testing</span> for our API service which talks to Discourse, however I now need t...","post_number":1,"topic_title_headline":"Seed or API calls to create <span class=\"search-highlight\">test</span> users","topic_id":126025},{"id":334186,"name":"Chris","username":"ChrisBeach","avatar_template":"/user_avatar/meta.discourse.org/chrisbeach/{size}/214628_2.png","created_at":"2017-10-01T08:18:48.148Z","like_count":1,"blurb":"...from the core team. I propose that on hitting the upgrade button, a new docker image is built in the background, and within it, acceptance <span class=\"search-highlight\">tests</span> of all plugins are run before the switch-over happens f...","post_number":1,"topic_title_headline":"Smoke-<span class=\"search-highlight\">testing</span> plugins during upgrade process","topic_id":71118},{"id":288991,"name":"David Taylor","username":"david","avatar_template":"/user_avatar/meta.discourse.org/david/{size}/157490_2.png","created_at":"2017-05-16T10:28:58.496Z","like_count":1,"blurb":"I'm trying to use the docker image for <span class=\"search-highlight\">tests</span> both on my mac, and also https://meta.discourse.org/t/setting-up-plugin-continuous-integration-tests-on-travis-ci/59612 on travis . For a while now the qun...","post_number":1,"topic_title_headline":"QUnit <span class=\"search-highlight\">tests</span> won’t pass in discourse_dev docker image","topic_id":62797},{"id":187533,"name":"Sckott","username":"sckott","avatar_template":"/user_avatar/meta.discourse.org/sckott/{size}/115359_2.png","created_at":"2016-04-21T19:15:00.191Z","like_count":0,"blurb":"What's the best or fastest way to get Discourse installed on Travis for <span class=\"search-highlight\">testing</span> a client for the Discourse API ? It appears as though the discourse_api gem uses webmock so I think does not use a real ...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Testing</span> a Discourse API client on Travis-CI","topic_id":42947},{"id":966756,"name":"Connor Parrish","username":"Connor_Parrish","avatar_template":"/user_avatar/meta.discourse.org/connor_parrish/{size}/225463_2.png","created_at":"2021-07-22T16:57:05.885Z","like_count":1,"blurb":"When you're conditionally adding a PostMenuButton using the plugin-api , the extra button is included in _extraButtons in between acceptance <span class=\"search-highlight\">tests</span>. When I run <span class=\"search-highlight\">tests</span>, if the <span class=\"search-highlight\">tests</span> where the button shou...","post_number":1,"topic_title_headline":"PostMenu’s ‘_extraButtons’ isn’t reset in between acceptance <span class=\"search-highlight\">tests</span>","topic_id":197887}],"topics":[{"id":49167,"title":"Write acceptance tests and component tests for Ember code in Discourse","fancy_title":"Write acceptance tests and component tests for Ember code in Discourse","slug":"write-acceptance-tests-and-component-tests-for-ember-code-in-discourse","posts_count":3,"reply_count":1,"highest_post_number":3,"created_at":"2016-08-24T20:48:02.492Z","last_posted_at":"2017-02-01T18:22:01.859Z","bumped":true,"bumped_at":"2017-02-01T18:22:01.859Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["tutorial","ember","testing"],"tags_descriptions":{},"category_id":56,"has_accepted_answer":false},{"id":32619,"title":"Developing Discourse Plugins - Part 6 - Add acceptance tests","fancy_title":"Developing Discourse Plugins - Part 6 - Add acceptance tests","slug":"developing-discourse-plugins-part-6-add-acceptance-tests","posts_count":33,"reply_count":26,"highest_post_number":38,"created_at":"2015-08-27T21:32:26.323Z","last_posted_at":"2022-06-02T11:06:38.274Z","bumped":true,"bumped_at":"2022-06-02T11:06:38.274Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["plugins","tutorial","plugin-guides","testing"],"tags_descriptions":{"plugins":""},"category_id":56,"has_accepted_answer":false},{"id":281579,"title":"End-to-end system testing for themes and theme components","fancy_title":"End-to-end system testing for themes and theme components","slug":"end-to-end-system-testing-for-themes-and-theme-components","posts_count":1,"reply_count":0,"highest_post_number":1,"created_at":"2023-10-24T23:13:37.118Z","last_posted_at":"2023-10-24T23:13:37.118Z","bumped":true,"bumped_at":"2023-11-13T23:20:01.596Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to","themes"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":56,"has_accepted_answer":false},{"id":66857,"title":"How to run Discourse core, plugin and theme QUnit test suites","fancy_title":"How to run Discourse core, plugin and theme QUnit test suites","slug":"how-to-run-discourse-core-plugin-and-theme-qunit-test-suites","posts_count":1,"reply_count":2,"highest_post_number":1,"created_at":"2017-07-26T14:09:58.032Z","last_posted_at":"2017-07-26T14:09:58.126Z","bumped":true,"bumped_at":"2023-09-04T17:56:33.079Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":56,"has_accepted_answer":false},{"id":17155,"title":"Test Discourse in mobile screen emulator","fancy_title":"Test Discourse in mobile screen emulator","slug":"test-discourse-in-mobile-screen-emulator","posts_count":3,"reply_count":1,"highest_post_number":3,"created_at":"2014-07-03T11:45:09.360Z","last_posted_at":"2014-10-12T22:19:24.137Z","bumped":true,"bumped_at":"2014-10-12T22:19:24.137Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":56,"has_accepted_answer":false},{"id":288363,"title":"Tip: when testing inbound email with fake user accounts...","fancy_title":"Tip: when testing inbound email with fake user accounts…","slug":"tip-when-testing-inbound-email-with-fake-user-accounts","posts_count":1,"reply_count":0,"highest_post_number":1,"created_at":"2023-12-12T10:51:07.868Z","last_posted_at":"2023-12-12T10:51:08.401Z","bumped":true,"bumped_at":"2023-12-12T10:51:08.401Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["email"],"tags_descriptions":{},"category_id":55,"has_accepted_answer":false},{"id":145744,"title":"Generate User API Keys for testing","fancy_title":"Generate User API Keys for testing","slug":"generate-user-api-keys-for-testing","posts_count":4,"reply_count":1,"highest_post_number":4,"created_at":"2020-03-26T21:31:38.269Z","last_posted_at":"2022-08-07T15:27:18.788Z","bumped":true,"bumped_at":"2022-08-07T15:27:18.788Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":56,"has_accepted_answer":false},{"id":58298,"title":"Build a sandbox to test changes before making them live","fancy_title":"Build a sandbox to test changes before making them live","slug":"build-a-sandbox-to-test-changes-before-making-them-live","posts_count":21,"reply_count":13,"highest_post_number":21,"created_at":"2017-03-03T13:34:07.921Z","last_posted_at":"2022-02-16T23:18:54.680Z","bumped":true,"bumped_at":"2022-02-16T23:18:54.680Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":55,"has_accepted_answer":false},{"id":118296,"title":"Migrate from tests-passed to stable","fancy_title":"Migrate from tests-passed to stable","slug":"migrate-from-tests-passed-to-stable","posts_count":12,"reply_count":7,"highest_post_number":12,"created_at":"2019-05-21T10:42:51.017Z","last_posted_at":"2019-06-21T15:55:53.072Z","bumped":true,"bumped_at":"2019-05-22T15:55:47.651Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":288705,"title":"502 Bad Gateway after online rebuild of tests-passed Production just now","fancy_title":"502 Bad Gateway after online rebuild of tests-passed Production just now","slug":"502-bad-gateway-after-online-rebuild-of-tests-passed-production-just-now","posts_count":6,"reply_count":1,"highest_post_number":6,"created_at":"2023-12-14T17:11:26.387Z","last_posted_at":"2024-01-13T17:37:27.535Z","bumped":true,"bumped_at":"2023-12-14T17:36:57.066Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":247546,"title":"Trouble on adding a simple unit test for Youtube oneboxing","fancy_title":"Trouble on adding a simple unit test for Youtube oneboxing","slug":"trouble-on-adding-a-simple-unit-test-for-youtube-oneboxing","posts_count":10,"reply_count":5,"highest_post_number":10,"created_at":"2022-12-02T20:49:55.713Z","last_posted_at":"2023-01-05T17:47:40.629Z","bumped":true,"bumped_at":"2022-12-06T17:47:28.771Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":["onebox","testing"],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":274165,"title":"Strange QUnit behaviour?: test failing because setting value doesn't survive","fancy_title":"Strange QUnit behaviour?: test failing because setting value doesn’t survive","slug":"strange-qunit-behaviour-test-failing-because-setting-value-doesnt-survive","posts_count":5,"reply_count":2,"highest_post_number":5,"created_at":"2023-08-07T10:05:22.298Z","last_posted_at":"2023-09-06T11:19:24.509Z","bumped":true,"bumped_at":"2023-08-07T11:24:27.150Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":249167,"title":"Is it possible to override the Site object with own fixture during Front End tests of a Plugin?","fancy_title":"Is it possible to override the Site object with own fixture during Front End tests of a Plugin?","slug":"is-it-possible-to-override-the-site-object-with-own-fixture-during-front-end-tests-of-a-plugin","posts_count":4,"reply_count":0,"highest_post_number":4,"created_at":"2022-12-16T18:32:29.079Z","last_posted_at":"2022-12-28T21:57:56.070Z","bumped":true,"bumped_at":"2022-12-28T21:57:56.070Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":286355,"title":"Acceptance tests failing on Github Actions","fancy_title":"Acceptance tests failing on Github Actions","slug":"acceptance-tests-failing-on-github-actions","posts_count":6,"reply_count":0,"highest_post_number":6,"created_at":"2023-11-22T18:46:46.128Z","last_posted_at":"2023-11-24T13:59:40.100Z","bumped":true,"bumped_at":"2023-11-24T13:59:40.100Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":91312,"title":"There was a problem sending the test email","fancy_title":"There was a problem sending the test email","slug":"there-was-a-problem-sending-the-test-email","posts_count":8,"reply_count":5,"highest_post_number":8,"created_at":"2018-07-01T17:12:48.366Z","last_posted_at":"2018-08-01T09:28:21.935Z","bumped":true,"bumped_at":"2018-07-02T09:28:16.014Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":287022,"title":"Strange migration error in tests during GH workflow","fancy_title":"Strange migration error in tests during GH workflow","slug":"strange-migration-error-in-tests-during-gh-workflow","posts_count":6,"reply_count":3,"highest_post_number":6,"created_at":"2023-11-29T23:18:11.686Z","last_posted_at":"2023-12-31T15:09:05.633Z","bumped":true,"bumped_at":"2023-12-01T15:08:31.257Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":238372,"title":"Smtp doctor test using port 465 even though its configured to use 2525","fancy_title":"Smtp doctor test using port 465 even though its configured to use 2525","slug":"smtp-doctor-test-using-port-465-even-though-its-configured-to-use-2525","posts_count":12,"reply_count":7,"highest_post_number":12,"created_at":"2022-09-07T20:22:10.976Z","last_posted_at":"2022-10-09T00:21:55.656Z","bumped":true,"bumped_at":"2022-09-09T00:21:48.272Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":146326,"title":"Sidekiq not running. Sidekiq heartbeat test failed, restarting","fancy_title":"Sidekiq not running. Sidekiq heartbeat test failed, restarting","slug":"sidekiq-not-running-sidekiq-heartbeat-test-failed-restarting","posts_count":16,"reply_count":9,"highest_post_number":16,"created_at":"2020-03-31T18:51:44.061Z","last_posted_at":"2020-06-10T01:39:28.366Z","bumped":true,"bumped_at":"2020-05-11T01:39:26.054Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":["unsupported-install"],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":255406,"title":"Tests for plugin that requires a plugin","fancy_title":"Tests for plugin that requires a plugin","slug":"tests-for-plugin-that-requires-a-plugin","posts_count":3,"reply_count":0,"highest_post_number":3,"created_at":"2023-02-16T22:12:10.344Z","last_posted_at":"2023-02-28T16:10:36.498Z","bumped":true,"bumped_at":"2023-02-28T20:21:06.122Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":246069,"title":"Need to restart Ember in order to test front-end changes","fancy_title":"Need to restart Ember in order to test front-end changes","slug":"need-to-restart-ember-in-order-to-test-front-end-changes","posts_count":5,"reply_count":1,"highest_post_number":5,"created_at":"2022-11-18T17:03:45.900Z","last_posted_at":"2022-11-18T18:17:53.648Z","bumped":true,"bumped_at":"2022-11-18T18:17:53.648Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":227090,"title":"Bitnami Discourse VM on Virtualbox + SMTP mail-server for testing","fancy_title":"Bitnami Discourse VM on Virtualbox + SMTP mail-server for testing","slug":"bitnami-discourse-vm-on-virtualbox-smtp-mail-server-for-testing","posts_count":3,"reply_count":0,"highest_post_number":3,"created_at":"2022-05-15T18:02:33.136Z","last_posted_at":"2022-05-16T09:10:43.794Z","bumped":true,"bumped_at":"2022-05-16T09:10:43.794Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":["unsupported-install"],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":145781,"title":"Test emails sent but","fancy_title":"Test emails sent but","slug":"test-emails-sent-but","posts_count":5,"reply_count":2,"highest_post_number":5,"created_at":"2020-03-27T07:14:10.116Z","last_posted_at":"2020-04-29T02:52:31.927Z","bumped":true,"bumped_at":"2020-03-30T02:52:29.062Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":137496,"title":"Sidekiq heartbeat test failed, restarting","fancy_title":"Sidekiq heartbeat test failed, restarting","slug":"sidekiq-heartbeat-test-failed-restarting","posts_count":13,"reply_count":8,"highest_post_number":13,"created_at":"2020-01-01T11:20:33.492Z","last_posted_at":"2020-02-11T23:09:42.375Z","bumped":true,"bumped_at":"2020-01-12T23:09:39.730Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":212684,"title":"MailGun & Discourse: Sandbox subdomains are for test purposes only. Please add your own domain","fancy_title":"MailGun & Discourse: Sandbox subdomains are for test purposes only. Please add your own domain","slug":"mailgun-discourse-sandbox-subdomains-are-for-test-purposes-only-please-add-your-own-domain","posts_count":4,"reply_count":2,"highest_post_number":5,"created_at":"2021-12-20T13:52:10.405Z","last_posted_at":"2022-01-19T15:27:11.368Z","bumped":true,"bumped_at":"2021-12-20T15:26:24.911Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":102035,"title":"Test admin features without having to install Discourse","fancy_title":"Test admin features without having to install Discourse","slug":"test-admin-features-without-having-to-install-discourse","posts_count":6,"reply_count":2,"highest_post_number":6,"created_at":"2018-11-14T15:20:59.484Z","last_posted_at":"2021-09-09T07:35:18.270Z","bumped":true,"bumped_at":"2021-09-09T07:35:18.270Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":121299,"title":"Install discourse with a staging (test) ssl certificate","fancy_title":"Install discourse with a staging (test) ssl certificate","slug":"install-discourse-with-a-staging-test-ssl-certificate","posts_count":5,"reply_count":1,"highest_post_number":5,"created_at":"2019-06-25T17:12:34.552Z","last_posted_at":"2023-04-01T03:25:32.925Z","bumped":true,"bumped_at":"2019-09-04T15:15:31.153Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":118568,"title":"Problem testing Badge Query from Data Explorer","fancy_title":"Problem testing Badge Query from Data Explorer","slug":"problem-testing-badge-query-from-data-explorer","posts_count":5,"reply_count":1,"highest_post_number":6,"created_at":"2019-05-24T00:14:35.814Z","last_posted_at":"2019-05-24T00:46:42.560Z","bumped":true,"bumped_at":"2019-05-24T00:46:42.560Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":["sql-triggered-badge"],"tags_descriptions":{"sql-triggered-badge":"SQL queries for custom triggered badges"},"category_id":148,"has_accepted_answer":true},{"id":115912,"title":"New iOS mobile app beta available for testing","fancy_title":"New iOS mobile app beta available for testing","slug":"new-ios-mobile-app-beta-available-for-testing","posts_count":49,"reply_count":31,"highest_post_number":49,"created_at":"2019-04-25T01:24:56.608Z","last_posted_at":"2019-05-31T17:31:02.516Z","bumped":true,"bumped_at":"2020-01-21T17:07:37.817Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":224560,"title":"502 Bad Gateway after trying to rebuild test-passed branch","fancy_title":"502 Bad Gateway after trying to rebuild test-passed branch","slug":"502-bad-gateway-after-trying-to-rebuild-test-passed-branch","posts_count":6,"reply_count":1,"highest_post_number":6,"created_at":"2022-04-17T21:46:04.598Z","last_posted_at":"2022-04-17T22:15:37.255Z","bumped":true,"bumped_at":"2022-04-17T22:15:37.255Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":false},{"id":173019,"title":"Forum offline due to failed rebuilds on Tests-Pass","fancy_title":"Forum offline due to failed rebuilds on Tests-Pass","slug":"forum-offline-due-to-failed-rebuilds-on-tests-pass","posts_count":2,"reply_count":0,"highest_post_number":2,"created_at":"2020-12-11T18:44:17.952Z","last_posted_at":"2020-12-11T19:12:11.141Z","bumped":true,"bumped_at":"2020-12-11T19:12:11.141Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":false},{"id":196501,"title":"[A/B Testing] Changing parent CSS class based on experiment variable","fancy_title":"[A/B Testing] Changing parent CSS class based on experiment variable","slug":"a-b-testing-changing-parent-css-class-based-on-experiment-variable","posts_count":3,"reply_count":1,"highest_post_number":3,"created_at":"2021-07-08T19:58:55.503Z","last_posted_at":"2021-07-15T18:19:21.092Z","bumped":true,"bumped_at":"2021-07-15T18:19:21.092Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":282856,"title":"Code-level performance testing","fancy_title":"Code-level performance testing","slug":"code-level-performance-testing","posts_count":2,"reply_count":0,"highest_post_number":2,"created_at":"2023-10-20T03:44:49.568Z","last_posted_at":"2023-10-23T23:29:59.741Z","bumped":true,"bumped_at":"2023-10-23T23:29:59.741Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":272112,"title":"Running core tests in docker environment","fancy_title":"Running core tests in docker environment","slug":"running-core-tests-in-docker-environment","posts_count":1,"reply_count":0,"highest_post_number":1,"created_at":"2023-07-19T12:45:57.266Z","last_posted_at":"2023-07-19T12:45:57.446Z","bumped":true,"bumped_at":"2023-07-19T12:45:57.446Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["docker","spec","testing"],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":87295,"title":"Email test from the console?","fancy_title":"Email test from the console?","slug":"email-test-from-the-console","posts_count":4,"reply_count":1,"highest_post_number":4,"created_at":"2018-05-11T22:03:23.101Z","last_posted_at":"2018-05-12T00:55:41.843Z","bumped":true,"bumped_at":"2018-05-12T00:55:41.843Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":59577,"title":"Plugin QUnit tests are not running as part of rake qunit:test","fancy_title":"Plugin QUnit tests are not running as part of rake qunit:test","slug":"plugin-qunit-tests-are-not-running-as-part-of-rake-qunit-test","posts_count":8,"reply_count":5,"highest_post_number":8,"created_at":"2017-03-20T20:29:06.536Z","last_posted_at":"2017-07-17T18:26:45.188Z","bumped":true,"bumped_at":"2017-07-17T18:26:45.188Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":181856,"title":"How does the Discourse team do usability testing?","fancy_title":"How does the Discourse team do usability testing?","slug":"how-does-the-discourse-team-do-usability-testing","posts_count":5,"reply_count":2,"highest_post_number":6,"created_at":"2021-03-03T18:09:29.357Z","last_posted_at":"2021-03-04T15:23:46.571Z","bumped":true,"bumped_at":"2021-03-04T15:23:46.571Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":3,"has_accepted_answer":false},{"id":108110,"title":"Advice on writing Ruby tests for plugins","fancy_title":"Advice on writing Ruby tests for plugins","slug":"advice-on-writing-ruby-tests-for-plugins","posts_count":15,"reply_count":13,"highest_post_number":15,"created_at":"2019-02-01T09:52:27.051Z","last_posted_at":"2019-05-03T03:54:47.163Z","bumped":true,"bumped_at":"2019-05-03T03:54:47.163Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":57292,"title":"Acceptance test content is present on page","fancy_title":"Acceptance test content is present on page","slug":"acceptance-test-content-is-present-on-page","posts_count":7,"reply_count":3,"highest_post_number":7,"created_at":"2017-02-13T02:48:10.108Z","last_posted_at":"2017-02-14T05:55:31.520Z","bumped":true,"bumped_at":"2017-02-14T05:55:31.520Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":291039,"title":"Google Search Console/Schema Markup test tool \"Google Rich Results Test\": mobile-view instead of crawler-view","fancy_title":"Google Search Console/Schema Markup test tool “Google Rich Results Test”: mobile-view instead of crawler-view","slug":"google-search-console-schema-markup-test-tool-google-rich-results-test-mobile-view-instead-of-crawler-view","posts_count":3,"reply_count":1,"highest_post_number":3,"created_at":"2024-01-09T16:53:57.797Z","last_posted_at":"2024-01-09T17:17:09.039Z","bumped":true,"bumped_at":"2024-01-09T17:17:09.039Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":1,"has_accepted_answer":false},{"id":267737,"title":"Experiments with Model-Based Testing","fancy_title":"Experiments with Model-Based Testing","slug":"experiments-with-model-based-testing","posts_count":1,"reply_count":0,"highest_post_number":1,"created_at":"2023-06-08T22:35:51.784Z","last_posted_at":"2023-06-08T22:35:51.914Z","bumped":true,"bumped_at":"2023-06-08T22:35:51.914Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":233389,"title":"Qunit tests not deterministic in Plugin?","fancy_title":"Qunit tests not deterministic in Plugin?","slug":"qunit-tests-not-deterministic-in-plugin","posts_count":2,"reply_count":0,"highest_post_number":2,"created_at":"2022-07-20T08:48:28.757Z","last_posted_at":"2022-07-20T11:03:42.522Z","bumped":true,"bumped_at":"2022-07-20T11:03:42.522Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":135876,"title":"Issues migrating test database","fancy_title":"Issues migrating test database","slug":"issues-migrating-test-database","posts_count":9,"reply_count":1,"highest_post_number":9,"created_at":"2019-12-12T21:01:31.303Z","last_posted_at":"2021-03-02T16:44:20.120Z","bumped":true,"bumped_at":"2021-03-02T16:44:20.120Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":175413,"title":"Acceptance Test for Markdown Extension?","fancy_title":"Acceptance Test for Markdown Extension?","slug":"acceptance-test-for-markdown-extension","posts_count":3,"reply_count":0,"highest_post_number":4,"created_at":"2021-01-07T19:56:04.931Z","last_posted_at":"2021-01-10T11:26:16.888Z","bumped":true,"bumped_at":"2021-01-10T16:30:56.164Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":150786,"title":"Auto migrate test database schema","fancy_title":"Auto migrate test database schema","slug":"auto-migrate-test-database-schema","posts_count":2,"reply_count":0,"highest_post_number":2,"created_at":"2020-05-08T04:27:56.739Z","last_posted_at":"2020-05-08T13:15:45.247Z","bumped":true,"bumped_at":"2020-05-08T13:15:45.247Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":118639,"title":"Building Tests for New discourse_api Endpoints","fancy_title":"Building Tests for New discourse_api Endpoints","slug":"building-tests-for-new-discourse-api-endpoints","posts_count":20,"reply_count":9,"highest_post_number":21,"created_at":"2019-05-24T22:21:24.896Z","last_posted_at":"2019-10-02T21:13:35.140Z","bumped":true,"bumped_at":"2019-10-02T21:36:26.639Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":126025,"title":"Seed or API calls to create test users","fancy_title":"Seed or API calls to create test users","slug":"seed-or-api-calls-to-create-test-users","posts_count":7,"reply_count":5,"highest_post_number":7,"created_at":"2019-08-17T02:13:06.578Z","last_posted_at":"2019-08-19T11:50:36.137Z","bumped":true,"bumped_at":"2019-08-19T11:50:36.137Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":71118,"title":"Smoke-testing plugins during upgrade process","fancy_title":"Smoke-testing plugins during upgrade process","slug":"smoke-testing-plugins-during-upgrade-process","posts_count":22,"reply_count":17,"highest_post_number":22,"created_at":"2017-10-01T08:18:48.067Z","last_posted_at":"2017-10-02T23:44:59.852Z","bumped":true,"bumped_at":"2017-10-02T23:44:59.852Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["pr-welcome"],"tags_descriptions":{"pr-welcome":"You're welcome to submit a Github pull request that implements this"},"category_id":2,"has_accepted_answer":false},{"id":62797,"title":"QUnit tests won't pass in discourse_dev docker image","fancy_title":"QUnit tests won’t pass in discourse_dev docker image","slug":"qunit-tests-wont-pass-in-discourse-dev-docker-image","posts_count":20,"reply_count":11,"highest_post_number":21,"created_at":"2017-05-16T10:28:58.397Z","last_posted_at":"2017-07-25T15:50:27.716Z","bumped":true,"bumped_at":"2017-07-25T15:50:27.716Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":42947,"title":"Testing a Discourse API client on Travis-CI","fancy_title":"Testing a Discourse API client on Travis-CI","slug":"testing-a-discourse-api-client-on-travis-ci","posts_count":5,"reply_count":3,"highest_post_number":5,"created_at":"2016-04-21T19:15:00.130Z","last_posted_at":"2016-04-22T03:03:47.976Z","bumped":true,"bumped_at":"2016-04-22T03:03:47.976Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":197887,"title":"PostMenu's '_extraButtons' isn't reset in between acceptance tests","fancy_title":"PostMenu’s ‘_extraButtons’ isn’t reset in between acceptance tests","slug":"postmenus-extrabuttons-isnt-reset-in-between-acceptance-tests","posts_count":2,"reply_count":0,"highest_post_number":4,"created_at":"2021-07-22T16:57:05.803Z","last_posted_at":"2021-08-04T09:11:29.538Z","bumped":true,"bumped_at":"2021-08-04T09:11:29.538Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false}],"users":[],"categories":[],"tags":[],"groups":[],"grouped_search_result":{"more_posts":null,"more_users":null,"more_categories":null,"term":"testing","search_log_id":2089836,"more_full_page_results":true,"can_create_topic":false,"error":null,"post_ids":[218354,138484,1381521,311252,59431,1419473,722424,266441,582008,1421414,1204506,1339808,1211823,1408389,443129,1412219,1160803,725228,1240758,1197679,1103913,722608,679950,1033333,498559,596557,583541,569209,1090520,860593,960683,1389188,1329017,421687,272436,899205,530438,260691,1432567,1305996,1135474,672801,871105,747613,583961,621707,334186,288991,187533,966756],"user_ids":[],"category_ids":[],"tag_ids":[],"group_ids":[]}} \ No newline at end of file diff --git a/spec/fixtures/search_meta/search_with_categories.json b/spec/fixtures/search_meta/search_with_categories.json deleted file mode 100644 index 6f412e0f..00000000 --- a/spec/fixtures/search_meta/search_with_categories.json +++ /dev/null @@ -1 +0,0 @@ -{"posts":[{"id":218354,"name":"Robin Ward","username":"eviltrout","avatar_template":"/user_avatar/meta.discourse.org/eviltrout/{size}/5275_2.png","created_at":"2016-08-24T20:48:02.587Z","like_count":15,"blurb":"Automated <span class=\"search-highlight\">tests</span> are a great way to protect your code against future regressions. Many people are familiar with how to do this in our Rails codebase with http://rspec.info/ rspec , but the Javascript s...","post_number":1,"topic_title_headline":"Write acceptance <span class=\"search-highlight\">tests</span> and component <span class=\"search-highlight\">tests</span> for Ember code in Discourse","topic_id":49167},{"id":138484,"name":"Robin Ward","username":"eviltrout","avatar_template":"/user_avatar/meta.discourse.org/eviltrout/{size}/5275_2.png","created_at":"2015-08-27T21:32:26.407Z","like_count":29,"blurb":"Previous tutorial: https://meta.discourse.org/t/developing-discourse-plugins-part-5-add-an-admin-interface/31761 Developing Discourse Plugins - Part 5 - Add an admin interface Did you know that Discou...","post_number":1,"topic_title_headline":"Developing Discourse Plugins - Part 6 - Add acceptance <span class=\"search-highlight\">tests</span>","topic_id":32619},{"id":1381521,"name":"Alan Tan","username":"tgxworld","avatar_template":"/user_avatar/meta.discourse.org/tgxworld/{size}/106117_2.png","created_at":"2023-10-24T23:13:37.118Z","like_count":16,"blurb":"Writing automated <span class=\"search-highlight\">tests</span> for themes is an important part of the theme development process which can help ensure that the features being introduced by a theme continues to work well overtime with core D...","post_number":1,"topic_title_headline":"End-to-end system <span class=\"search-highlight\">testing</span> for themes and theme components","topic_id":281579},{"id":311252,"name":"David Taylor","username":"david","avatar_template":"/user_avatar/meta.discourse.org/david/{size}/157490_2.png","created_at":"2017-07-26T14:09:58.126Z","like_count":17,"blurb":"Discourse has extensive frontend <span class=\"search-highlight\">tests</span> for core, plugins and themes. Once you have a functioning local development environment, those <span class=\"search-highlight\">tests</span> can be run locally in a number of different ways. Running te...","post_number":1,"topic_title_headline":"How to run Discourse core, plugin and theme QUnit <span class=\"search-highlight\">test</span> suites","topic_id":66857},{"id":59431,"name":"Erlend Sogge Heggen","username":"erlend_sh","avatar_template":"/user_avatar/meta.discourse.org/erlend_sh/{size}/119475_2.png","created_at":"2014-07-03T11:45:09.463Z","like_count":5,"blurb":"...view=1 to an URL, but the emulator has the added benefit of letting you select the screen profile of a specific device. I also <span class=\"search-highlight\">tested</span> all of the most popular online screen emulators, but unfortunately...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Test</span> Discourse in mobile screen emulator","topic_id":17155},{"id":1419473,"name":"","username":"ToddZ","avatar_template":"/user_avatar/meta.discourse.org/toddz/{size}/328350_2.png","created_at":"2023-12-12T10:51:08.401Z","like_count":2,"blurb":"...amount of time troubleshooting inbound email because Discourse was rejecting every reply-by-email from my fake users. It had worked fine when I first <span class=\"search-highlight\">tested</span> several weeks ago… I finally realized that ...","post_number":1,"topic_title_headline":"Tip: when <span class=\"search-highlight\">testing</span> inbound email with fake user accounts…","topic_id":288363},{"id":722424,"name":"Falco","username":"Falco","avatar_template":"/user_avatar/meta.discourse.org/falco/{size}/179432_2.png","created_at":"2020-03-26T21:31:38.463Z","like_count":17,"blurb":"Continuing the discussion from https://meta.discourse.org/t/user-api-keys-specification/48536 User API keys specification : I created a small utility script in order to <span class=\"search-highlight\">test</span> User API keys locally. Fir...","post_number":1,"topic_title_headline":"Generate User API Keys for <span class=\"search-highlight\">testing</span>","topic_id":145744},{"id":266441,"name":"Andrew Waugh","username":"JagWaugh","avatar_template":"/user_avatar/meta.discourse.org/jagwaugh/{size}/69335_2.png","created_at":"2017-03-03T13:34:08.009Z","like_count":19,"blurb":"Regardless of if you're a moderator or an admin, you will no doubt at some time think about making some change to your live site and wonder if this will bring shame on you, and/or cause yourself an en...","post_number":1,"topic_title_headline":"Build a sandbox to <span class=\"search-highlight\">test</span> changes before making them live","topic_id":58298},{"id":582008,"name":"","username":"Wurzelseppi","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/w/eada6e/{size}.png","created_at":"2019-05-21T10:42:51.099Z","like_count":0,"blurb":"Hi guys, just wanted to migrate from 2.3.0beta9 to stable release and got this error: What can I do here ? Caused by: PG::UndefinedColumn: ERROR: column \"email_private_messages\" of relation \"user_opti...","post_number":1,"topic_title_headline":"Migrate from <span class=\"search-highlight\">tests</span>-passed to stable","topic_id":118296},{"id":1421414,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2023-12-14T17:11:26.575Z","like_count":1,"blurb":"Restarting the server, restarting the container, console rebuilding doesn't help. Container is up as I can ./launcher enter app Got a bunch of these in logs, ideas on how to investigate redis failure?...","post_number":1,"topic_title_headline":"502 Bad Gateway after online rebuild of <span class=\"search-highlight\">tests</span>-passed Production just now","topic_id":288705},{"id":1204506,"name":"Coin-coin le Canapin","username":"Canapin","avatar_template":"/user_avatar/meta.discourse.org/canapin/{size}/119591_2.png","created_at":"2022-12-02T20:49:55.867Z","like_count":1,"blurb":"Hi! I want to add support of /shorts/ Youtube link. My modification of the YoutubeOnebox class works, but it is required that I add a <span class=\"search-highlight\">test</span> in https://github.com/discourse/discourse/blob/493d437e79f88a...","post_number":1,"topic_title_headline":"Trouble on adding a simple unit <span class=\"search-highlight\">test</span> for Youtube oneboxing","topic_id":247546},{"id":1339808,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2023-08-07T10:05:22.444Z","like_count":2,"blurb":"I have a strange issue with QUnit. This <span class=\"search-highlight\">test</span> is extremely simple and should be straightforward … but … A plugin setting is changing from those I have set up. https://github.com/paviliondev/discourse-l...","post_number":1,"topic_title_headline":"Strange QUnit behaviour?: <span class=\"search-highlight\">test</span> failing because setting value doesn’t survive","topic_id":274165},{"id":1211823,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2022-12-16T18:32:29.231Z","like_count":1,"blurb":"...presenting formatted location on User Card by merefield · Pull Request #73 · paviliondev/discourse-locations · GitHub I'm attempting to cover the change with a new Front End <span class=\"search-highlight\">test</span>. But the <span class=\"search-highlight\">test</span> fails t...","post_number":1,"topic_title_headline":"Is it possible to override the Site object with own fixture during Front End <span class=\"search-highlight\">tests</span> of a Plugin?","topic_id":249167},{"id":1408389,"name":"Pierre Romera","username":"pirhoo","avatar_template":"/user_avatar/meta.discourse.org/pirhoo/{size}/120058_2.png","created_at":"2023-11-22T18:46:46.469Z","like_count":5,"blurb":"...m setting up a new plugin based on the https://github.com/discourse/discourse-plugin-skeleton/tree/main/assets skeleton you provided which already helped me a lot. I am now writing <span class=\"search-highlight\">tests</span>, both for the...","post_number":1,"topic_title_headline":"Acceptance <span class=\"search-highlight\">tests</span> failing on Github Actions","topic_id":286355},{"id":443129,"name":"JK Baseer","username":"JKBaseer","avatar_template":"/user_avatar/meta.discourse.org/jkbaseer/{size}/80471_2.png","created_at":"2018-07-01T17:12:48.446Z","like_count":0,"blurb":"...but still could myself. Background: I installed discourse using digitalocean oneclick installer. The website is running under http://forum.example.org forum.example.org without any problem except the ...","post_number":1,"topic_title_headline":"There was a problem sending the <span class=\"search-highlight\">test</span> email","topic_id":91312},{"id":1412219,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2023-11-29T23:18:11.815Z","like_count":1,"blurb":"I'm trying to create a foreign key relationship with the Topics table. The problem is it is failing in github workflow <span class=\"search-highlight\">test</span> environment during <span class=\"search-highlight\">tests</span> for the strangest reason, it is trying to access a ...","post_number":1,"topic_title_headline":"Strange migration error in <span class=\"search-highlight\">tests</span> during GH workflow","topic_id":287022},{"id":1160803,"name":"Bryan Joseph","username":"Bryan_Joseph","avatar_template":"/user_avatar/meta.discourse.org/bryan_joseph/{size}/273248_2.png","created_at":"2022-09-07T20:22:11.191Z","like_count":1,"blurb":"...SETTINGS ==================== DISCOURSE_HOSTNAME=url SMTP_ADDRESS=smtp.mailgun.org DEVELOPER_EMAILS=REDACTED SMTP_PASSWORD=REDACTED SMTP_PORT=2525 SMTP_USER_NAME=url LETSENCRYPT_ACCOUNT_EMAIL=REDACTED...","post_number":1,"topic_title_headline":"Smtp doctor <span class=\"search-highlight\">test</span> using port 465 even though its configured to use 2525","topic_id":238372},{"id":725228,"name":"james.network","username":"sunjam","avatar_template":"/user_avatar/meta.discourse.org/sunjam/{size}/175682_2.png","created_at":"2020-03-31T18:51:44.216Z","like_count":0,"blurb":"...Redis or updating it; it hasn't really been touched in the last 8+ months. I have not personally dealt with Redis before, but our <span class=\"search-highlight\">Tests</span>-Pass Discourse instance was setup using https://hub.docker.com/r...","post_number":1,"topic_title_headline":"Sidekiq not running. Sidekiq heartbeat <span class=\"search-highlight\">test</span> failed, restarting","topic_id":146326},{"id":1240758,"name":"Jay Pfaffman","username":"pfaffman","avatar_template":"/user_avatar/meta.discourse.org/pfaffman/{size}/120154_2.png","created_at":"2023-02-16T22:12:10.456Z","like_count":4,"blurb":"I see that the discourse-plugin-skeleton now has this: uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1 so we don't have to keep updating stuff. But I have a plugin that requires the ...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Tests</span> for plugin that requires a plugin","topic_id":255406},{"id":1197679,"name":"","username":"SilK","avatar_template":"/user_avatar/meta.discourse.org/silk/{size}/268124_2.png","created_at":"2022-11-18T17:03:46.056Z","like_count":0,"blurb":"...a new dev environment for working on plugins. Discourse is up to date with the main branch. I need to restart Ember in order to <span class=\"search-highlight\">test</span> changes made to the front end. This includes changes to Handlebars,...","post_number":1,"topic_title_headline":"Need to restart Ember in order to <span class=\"search-highlight\">test</span> front-end changes","topic_id":246069},{"id":1103913,"name":"Banibrata Dutta","username":"bdutta","avatar_template":"/user_avatar/meta.discourse.org/bdutta/{size}/259973_2.png","created_at":"2022-05-15T18:02:33.290Z","like_count":0,"blurb":"...only in a captive host-only testbed, so wondering if there is any local network SMTP daemon / service that I could start to complete the <span class=\"search-highlight\">testing</span> ? I'm happy with 100% command line mail client and serv...","post_number":1,"topic_title_headline":"Bitnami Discourse VM on Virtualbox + SMTP mail-server for <span class=\"search-highlight\">testing</span>","topic_id":227090},{"id":722608,"name":"Lona Lee","username":"Lona_Lee","avatar_template":"/user_avatar/meta.discourse.org/lona_lee/{size}/169072_2.png","created_at":"2020-03-27T07:14:10.239Z","like_count":1,"blurb":"Hello. I'm trying to get email setup working on my discourse instance. Done set-up properly and looks fine(no errors), so sent <span class=\"search-highlight\">test</span> emails. (logs confirmed : \"Admin\" - \"Emails\" - \"Sent\") However, I ha...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Test</span> emails sent but","topic_id":145781},{"id":679950,"name":"Oleg Bovykin","username":"arrowcircle","avatar_template":"/user_avatar/meta.discourse.org/arrowcircle/{size}/100035_2.png","created_at":"2020-01-01T11:20:33.618Z","like_count":1,"blurb":"Hi! I found strange error in my admin page, that sidekiq is not running. I opened logs and found hundreds errors like: /var/www/discourse/vendor/bundle/ruby/2.6.0/gems/logster-2.5.1/lib/logster/logger...","post_number":1,"topic_title_headline":"Sidekiq heartbeat <span class=\"search-highlight\">test</span> failed, restarting","topic_id":137496},{"id":1033333,"name":"М. М.","username":"М_М","avatar_template":"/user_avatar/meta.discourse.org/м_м/{size}/243710_2.png","created_at":"2021-12-20T13:52:10.488Z","like_count":0,"blurb":"...user, the logs say like this Job exception: could not get 3xx (421: 421 Domain sandbox410fe5c7bb85483c941c05b4ec5f3495.mailgun.org is not allowed to send: Sandbox subdomains are for <span class=\"search-highlight\">test</span> purposes only...","post_number":1,"topic_title_headline":"MailGun & Discourse: Sandbox subdomains are for <span class=\"search-highlight\">test</span> purposes only. Please add your own domain","topic_id":212684},{"id":498559,"name":"","username":"desrocchi","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/d/eb9ed0/{size}.png","created_at":"2018-11-14T15:20:59.607Z","like_count":0,"blurb":"Is there a way for me to see or <span class=\"search-highlight\">test</span> the admin options in the demo area? I am just a moderator on the platform we use but I would like to see which options could be of use without having to install th...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Test</span> admin features without having to install Discourse","topic_id":102035},{"id":596557,"name":"Flaviu","username":"UnivacTwo","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/u/df705f/{size}.png","created_at":"2019-06-25T17:12:34.710Z","like_count":0,"blurb":"Let's encrypt has a limit of how many certificates can be generated in a week for the same domain. Unfortunately we reach this limit and we cannot generate a new certificate this week. We did a backup...","post_number":1,"topic_title_headline":"Install discourse with a staging (<span class=\"search-highlight\">test</span>) ssl certificate","topic_id":121299},{"id":583541,"name":"mark78","username":"Mark_Schmucker","avatar_template":"/user_avatar/meta.discourse.org/mark_schmucker/{size}/124810_2.png","created_at":"2019-05-24T00:14:35.895Z","like_count":0,"blurb":"Should I be able to run any Badge Query in https://meta.discourse.org/t/32566 Data Explorer ? I want to create a custom Badge Query using \"Appreciated\" as a starting point. I type the Appreciated quer...","post_number":1,"topic_title_headline":"Problem <span class=\"search-highlight\">testing</span> Badge Query from Data Explorer","topic_id":118568},{"id":569209,"name":"Penar Musaraj","username":"pmusaraj","avatar_template":"/user_avatar/meta.discourse.org/pmusaraj/{size}/119489_2.png","created_at":"2019-04-25T01:24:56.741Z","like_count":26,"blurb":"...device and installing the app via TestFlight: https://testflight.apple.com/join/NkdBQgmg testflight.apple.com https://testflight.apple.com/join/NkdBQgmg TestFlight - Apple Using TestFlight is a great ...","post_number":1,"topic_title_headline":"New iOS mobile app beta available for <span class=\"search-highlight\">testing</span>","topic_id":115912},{"id":1090520,"name":"Mac玩儿法","username":"waerfa","avatar_template":"/user_avatar/meta.discourse.org/waerfa/{size}/216044_2.png","created_at":"2022-04-17T21:46:04.755Z","like_count":0,"blurb":"...rebuild the container: git pull ./launcher rebuild app I got the fatal error which shows: FAILED -------------------- Pups::ExecError: cd /var/www/discourse & & git fetch --depth 1 origin <span class=\"search-highlight\">tests</span>-passed...","post_number":1,"topic_title_headline":"502 Bad Gateway after trying to rebuild <span class=\"search-highlight\">test</span>-passed branch","topic_id":224560},{"id":860593,"name":"james.network","username":"sunjam","avatar_template":"/user_avatar/meta.discourse.org/sunjam/{size}/175682_2.png","created_at":"2020-12-11T18:44:18.021Z","like_count":1,"blurb":"Continuing the discussion from https://meta.discourse.org/t/postgresql-13-update/172563/27 PostgreSQL 13 update : Run into trouble while updating 2.7.0beta1 <span class=\"search-highlight\">Tests</span>-Pass in order to remove some troubles...","post_number":1,"topic_title_headline":"Forum offline due to failed rebuilds on <span class=\"search-highlight\">Tests</span>-Pass","topic_id":173019},{"id":960683,"name":"","username":"daniyal","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/d/58f4c7/{size}.png","created_at":"2021-07-08T19:58:55.634Z","like_count":3,"blurb":"...which we want to experiment. An example would be to experiment different styles of topic list view. For this we are using Google Optimize A/B <span class=\"search-highlight\">testing</span>. Currently we plan to show theme without changes t...","post_number":1,"topic_title_headline":"[A/B <span class=\"search-highlight\">Testing</span>] Changing parent CSS class based on experiment variable","topic_id":196501},{"id":1389188,"name":"Angus McLeod","username":"angus","avatar_template":"/user_avatar/meta.discourse.org/angus/{size}/341715_2.png","created_at":"2023-10-20T03:44:49.737Z","like_count":7,"blurb":"I've been looking at the performance of the https://meta.discourse.org/t/activitypub-plugin/266794 ActivityPub plugin recently and considering the best ways to reliably <span class=\"search-highlight\">test</span>, and prove, performance fo...","post_number":1,"topic_title_headline":"Code-level performance <span class=\"search-highlight\">testing</span>","topic_id":282856},{"id":1329017,"name":"","username":"dodibi","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/d/9fc348/{size}.png","created_at":"2023-07-19T12:45:57.446Z","like_count":0,"blurb":"Hello everyone! I'm currently facing some challenges while configuring my local environment to run discourse <span class=\"search-highlight\">tests</span> in a docker container. My main objective is to run the core <span class=\"search-highlight\">tests</span> with plugins attach...","post_number":1,"topic_title_headline":"Running core <span class=\"search-highlight\">tests</span> in docker environment","topic_id":272112},{"id":421687,"name":"Jay Pfaffman","username":"pfaffman","avatar_template":"/user_avatar/meta.discourse.org/pfaffman/{size}/120154_2.png","created_at":"2018-05-11T22:03:23.232Z","like_count":0,"blurb":"Is there a way to initiate an email <span class=\"search-highlight\">test</span> from the Rails console? For a zillion reasons I would love to be able to send a <span class=\"search-highlight\">test</span> email without having to create an account. I've looked in config/routes.rb...","post_number":1,"topic_title_headline":"Email <span class=\"search-highlight\">test</span> from the console?","topic_id":87295},{"id":272436,"name":"David Taylor","username":"david","avatar_template":"/user_avatar/meta.discourse.org/david/{size}/157490_2.png","created_at":"2017-03-20T20:29:06.608Z","like_count":1,"blurb":"It is my understanding that running rake qunit:<span class=\"search-highlight\">test</span> should run all of the qunit <span class=\"search-highlight\">tests</span> in Discourse, including those for any installed plugins. However, when I run the task in the docker development en...","post_number":1,"topic_title_headline":"Plugin QUnit <span class=\"search-highlight\">tests</span> are not running as part of rake qunit:<span class=\"search-highlight\">test</span>","topic_id":59577},{"id":899205,"name":"","username":"JQ331","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/j/41988e/{size}.png","created_at":"2021-03-03T18:09:29.452Z","like_count":6,"blurb":"I came across this https://blog.codinghorror.com/low-fi-usability-testing/ excellent article on how to do low-fi usability <span class=\"search-highlight\">testing</span> by @codinghorror . Usability <span class=\"search-highlight\">testing</span> (and user <span class=\"search-highlight\">testing</span> in general) is...","post_number":1,"topic_title_headline":"How does the Discourse team do usability <span class=\"search-highlight\">testing</span>?","topic_id":181856},{"id":530438,"name":"","username":"kleinfreund","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/k/a6a055/{size}.png","created_at":"2019-02-01T09:52:27.150Z","like_count":1,"blurb":"One can write <span class=\"search-highlight\">tests</span> for the backend of a plugin. For example, I created the following file in my plugin directory: spec/lib/route_store_spec.rb : require 'rails_helper' describe MyPlugin::RouteStore d...","post_number":1,"topic_title_headline":"Advice on writing Ruby <span class=\"search-highlight\">tests</span> for plugins","topic_id":108110},{"id":260691,"name":"Rimian Perkins","username":"rimian","avatar_template":"/user_avatar/meta.discourse.org/rimian/{size}/120658_2.png","created_at":"2017-02-13T02:48:10.177Z","like_count":0,"blurb":"What's the best way to (QUnit) assert an element on the page has some content in it? This passes: ok($.trim($('.foo').text()) == 'bar', 'content bar renders on page'); But isn't very practical. Is the...","post_number":1,"topic_title_headline":"Acceptance <span class=\"search-highlight\">test</span> content is present on page","topic_id":57292},{"id":1432567,"name":"Ayke","username":"rrit","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/r/b5ac83/{size}.png","created_at":"2024-01-09T16:53:57.916Z","like_count":0,"blurb":"Right now Discourse on meta.discourse.org serves the mobile-view instead of the crawler-view to the https://search.google.com/test/rich-results Google Rich Results <span class=\"search-highlight\">Test</span> . As there is no Schema Markup ...","post_number":1,"topic_title_headline":"Google Search Console/Schema Markup <span class=\"search-highlight\">test</span> tool “Google Rich Results <span class=\"search-highlight\">Test</span>”: mobile-view instead of crawler-view","topic_id":291039},{"id":1305996,"name":"Larry Diehl","username":"larrytheliquid","avatar_template":"/user_avatar/meta.discourse.org/larrytheliquid/{size}/310576_2.png","created_at":"2023-06-08T22:35:51.914Z","like_count":1,"blurb":"Hi Discourse Community! :slight_smile: I've been working on tech ( https://colimit.io Colimit ) that helps people apply https://en.wikipedia.org/wiki/Model-based_testing Model-based <span class=\"search-highlight\">testing</span> to <span class=\"search-highlight\">test</span> th...","post_number":1,"topic_title_headline":"Experiments with Model-Based <span class=\"search-highlight\">Testing</span>","topic_id":267737},{"id":1135474,"name":"Robert","username":"merefield","avatar_template":"/user_avatar/meta.discourse.org/merefield/{size}/176214_2.png","created_at":"2022-07-20T08:48:28.868Z","like_count":0,"blurb":"...my case running: rake \"plugin:qunit[discourse-multilingual]\" with a branch installed. I'm declaring a function in my initializer (i'm extending I18n ) The <span class=\"search-highlight\">tests</span> sometimes (25%?) seem to run before the...","post_number":1,"topic_title_headline":"Qunit <span class=\"search-highlight\">tests</span> not deterministic in Plugin?","topic_id":233389},{"id":672801,"name":"Jay Pfaffman","username":"pfaffman","avatar_template":"/user_avatar/meta.discourse.org/pfaffman/{size}/120154_2.png","created_at":"2019-12-12T21:01:31.303Z","like_count":0,"blurb":"Thanks, @Mittineague ! After a while, that made sense. I even wrote a spec, but even before I added my spec (and when I reverted to before I added any code), specs fail because: An error occurred whil...","post_number":1,"topic_title_headline":"Issues migrating <span class=\"search-highlight\">test</span> database","topic_id":135876},{"id":871105,"name":"","username":"Alteras","avatar_template":"/user_avatar/meta.discourse.org/alteras/{size}/179824_2.png","created_at":"2021-01-07T19:56:05.071Z","like_count":4,"blurb":"Hello! I'm currently working on a Markdown Extension/plugin that adds quite a number of BBCode tags, and I am looking to write QUnit Acceptance <span class=\"search-highlight\">tests</span> for them (I got really tired of constantly checkin...","post_number":1,"topic_title_headline":"Acceptance <span class=\"search-highlight\">Test</span> for Markdown Extension?","topic_id":175413},{"id":747613,"name":"","username":"xrav3nz","avatar_template":"/user_avatar/meta.discourse.org/xrav3nz/{size}/76894_2.png","created_at":"2020-05-08T04:27:56.920Z","like_count":8,"blurb":"...development - #2 by taylorthurlow - A May Of WTFs - Ruby on Rails Discussions Not sure if we have explored this before, but Rails can automatically maintain <span class=\"search-highlight\">test</span> databse schema with ActiveRecord::Migr...","post_number":1,"topic_title_headline":"Auto migrate <span class=\"search-highlight\">test</span> database schema","topic_id":150786},{"id":583961,"name":"Kim Miller","username":"kimardenmiller","avatar_template":"/user_avatar/meta.discourse.org/kimardenmiller/{size}/119631_2.png","created_at":"2019-05-24T22:21:24.996Z","like_count":3,"blurb":"Adding some polls API endpoints for PR to discourse_api, which work fine. Now I'm trying to understand how to create <span class=\"search-highlight\">tests</span> before submitting the PR, e.g.: require 'spec_helper' describe DiscourseApi::...","post_number":1,"topic_title_headline":"Building <span class=\"search-highlight\">Tests</span> for New discourse_api Endpoints","topic_id":118639},{"id":621707,"name":"Andrew Lank","username":"alank","avatar_template":"https://avatars.discourse-cdn.com/v4/letter/a/c89c15/{size}.png","created_at":"2019-08-17T02:13:06.679Z","like_count":1,"blurb":"In my development and <span class=\"search-highlight\">testing</span> I'm running a Discourse instance (Docker Discourse from Bitnami) and it fulfills most of my API <span class=\"search-highlight\">testing</span> for our API service which talks to Discourse, however I now need t...","post_number":1,"topic_title_headline":"Seed or API calls to create <span class=\"search-highlight\">test</span> users","topic_id":126025},{"id":334186,"name":"Chris","username":"ChrisBeach","avatar_template":"/user_avatar/meta.discourse.org/chrisbeach/{size}/214628_2.png","created_at":"2017-10-01T08:18:48.148Z","like_count":1,"blurb":"...from the core team. I propose that on hitting the upgrade button, a new docker image is built in the background, and within it, acceptance <span class=\"search-highlight\">tests</span> of all plugins are run before the switch-over happens f...","post_number":1,"topic_title_headline":"Smoke-<span class=\"search-highlight\">testing</span> plugins during upgrade process","topic_id":71118},{"id":288991,"name":"David Taylor","username":"david","avatar_template":"/user_avatar/meta.discourse.org/david/{size}/157490_2.png","created_at":"2017-05-16T10:28:58.496Z","like_count":1,"blurb":"I'm trying to use the docker image for <span class=\"search-highlight\">tests</span> both on my mac, and also https://meta.discourse.org/t/setting-up-plugin-continuous-integration-tests-on-travis-ci/59612 on travis . For a while now the qun...","post_number":1,"topic_title_headline":"QUnit <span class=\"search-highlight\">tests</span> won’t pass in discourse_dev docker image","topic_id":62797},{"id":187533,"name":"Sckott","username":"sckott","avatar_template":"/user_avatar/meta.discourse.org/sckott/{size}/115359_2.png","created_at":"2016-04-21T19:15:00.191Z","like_count":0,"blurb":"What's the best or fastest way to get Discourse installed on Travis for <span class=\"search-highlight\">testing</span> a client for the Discourse API ? It appears as though the discourse_api gem uses webmock so I think does not use a real ...","post_number":1,"topic_title_headline":"<span class=\"search-highlight\">Testing</span> a Discourse API client on Travis-CI","topic_id":42947},{"id":966756,"name":"Connor Parrish","username":"Connor_Parrish","avatar_template":"/user_avatar/meta.discourse.org/connor_parrish/{size}/225463_2.png","created_at":"2021-07-22T16:57:05.885Z","like_count":1,"blurb":"When you're conditionally adding a PostMenuButton using the plugin-api , the extra button is included in _extraButtons in between acceptance <span class=\"search-highlight\">tests</span>. When I run <span class=\"search-highlight\">tests</span>, if the <span class=\"search-highlight\">tests</span> where the button shou...","post_number":1,"topic_title_headline":"PostMenu’s ‘_extraButtons’ isn’t reset in between acceptance <span class=\"search-highlight\">tests</span>","topic_id":197887}],"topics":[{"id":49167,"title":"Write acceptance tests and component tests for Ember code in Discourse","fancy_title":"Write acceptance tests and component tests for Ember code in Discourse","slug":"write-acceptance-tests-and-component-tests-for-ember-code-in-discourse","posts_count":3,"reply_count":1,"highest_post_number":3,"created_at":"2016-08-24T20:48:02.492Z","last_posted_at":"2017-02-01T18:22:01.859Z","bumped":true,"bumped_at":"2017-02-01T18:22:01.859Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["tutorial","ember","testing"],"tags_descriptions":{},"category_id":56,"has_accepted_answer":false},{"id":32619,"title":"Developing Discourse Plugins - Part 6 - Add acceptance tests","fancy_title":"Developing Discourse Plugins - Part 6 - Add acceptance tests","slug":"developing-discourse-plugins-part-6-add-acceptance-tests","posts_count":33,"reply_count":26,"highest_post_number":38,"created_at":"2015-08-27T21:32:26.323Z","last_posted_at":"2022-06-02T11:06:38.274Z","bumped":true,"bumped_at":"2022-06-02T11:06:38.274Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["plugins","tutorial","plugin-guides","testing"],"tags_descriptions":{"plugins":""},"category_id":56,"has_accepted_answer":false},{"id":281579,"title":"End-to-end system testing for themes and theme components","fancy_title":"End-to-end system testing for themes and theme components","slug":"end-to-end-system-testing-for-themes-and-theme-components","posts_count":1,"reply_count":0,"highest_post_number":1,"created_at":"2023-10-24T23:13:37.118Z","last_posted_at":"2023-10-24T23:13:37.118Z","bumped":true,"bumped_at":"2023-11-13T23:20:01.596Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to","themes"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":56,"has_accepted_answer":false},{"id":66857,"title":"How to run Discourse core, plugin and theme QUnit test suites","fancy_title":"How to run Discourse core, plugin and theme QUnit test suites","slug":"how-to-run-discourse-core-plugin-and-theme-qunit-test-suites","posts_count":1,"reply_count":2,"highest_post_number":1,"created_at":"2017-07-26T14:09:58.032Z","last_posted_at":"2017-07-26T14:09:58.126Z","bumped":true,"bumped_at":"2023-09-04T17:56:33.079Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":56,"has_accepted_answer":false},{"id":17155,"title":"Test Discourse in mobile screen emulator","fancy_title":"Test Discourse in mobile screen emulator","slug":"test-discourse-in-mobile-screen-emulator","posts_count":3,"reply_count":1,"highest_post_number":3,"created_at":"2014-07-03T11:45:09.360Z","last_posted_at":"2014-10-12T22:19:24.137Z","bumped":true,"bumped_at":"2014-10-12T22:19:24.137Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":56,"has_accepted_answer":false},{"id":288363,"title":"Tip: when testing inbound email with fake user accounts...","fancy_title":"Tip: when testing inbound email with fake user accounts…","slug":"tip-when-testing-inbound-email-with-fake-user-accounts","posts_count":1,"reply_count":0,"highest_post_number":1,"created_at":"2023-12-12T10:51:07.868Z","last_posted_at":"2023-12-12T10:51:08.401Z","bumped":true,"bumped_at":"2023-12-12T10:51:08.401Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["email"],"tags_descriptions":{},"category_id":55,"has_accepted_answer":false},{"id":145744,"title":"Generate User API Keys for testing","fancy_title":"Generate User API Keys for testing","slug":"generate-user-api-keys-for-testing","posts_count":4,"reply_count":1,"highest_post_number":4,"created_at":"2020-03-26T21:31:38.269Z","last_posted_at":"2022-08-07T15:27:18.788Z","bumped":true,"bumped_at":"2022-08-07T15:27:18.788Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":56,"has_accepted_answer":false},{"id":58298,"title":"Build a sandbox to test changes before making them live","fancy_title":"Build a sandbox to test changes before making them live","slug":"build-a-sandbox-to-test-changes-before-making-them-live","posts_count":21,"reply_count":13,"highest_post_number":21,"created_at":"2017-03-03T13:34:07.921Z","last_posted_at":"2022-02-16T23:18:54.680Z","bumped":true,"bumped_at":"2022-02-16T23:18:54.680Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["how-to"],"tags_descriptions":{"how-to":"How to guides contain steps to follow to solve a specific problem"},"category_id":55,"has_accepted_answer":false},{"id":118296,"title":"Migrate from tests-passed to stable","fancy_title":"Migrate from tests-passed to stable","slug":"migrate-from-tests-passed-to-stable","posts_count":12,"reply_count":7,"highest_post_number":12,"created_at":"2019-05-21T10:42:51.017Z","last_posted_at":"2019-06-21T15:55:53.072Z","bumped":true,"bumped_at":"2019-05-22T15:55:47.651Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":288705,"title":"502 Bad Gateway after online rebuild of tests-passed Production just now","fancy_title":"502 Bad Gateway after online rebuild of tests-passed Production just now","slug":"502-bad-gateway-after-online-rebuild-of-tests-passed-production-just-now","posts_count":6,"reply_count":1,"highest_post_number":6,"created_at":"2023-12-14T17:11:26.387Z","last_posted_at":"2024-01-13T17:37:27.535Z","bumped":true,"bumped_at":"2023-12-14T17:36:57.066Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":247546,"title":"Trouble on adding a simple unit test for Youtube oneboxing","fancy_title":"Trouble on adding a simple unit test for Youtube oneboxing","slug":"trouble-on-adding-a-simple-unit-test-for-youtube-oneboxing","posts_count":10,"reply_count":5,"highest_post_number":10,"created_at":"2022-12-02T20:49:55.713Z","last_posted_at":"2023-01-05T17:47:40.629Z","bumped":true,"bumped_at":"2022-12-06T17:47:28.771Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":["onebox","testing"],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":274165,"title":"Strange QUnit behaviour?: test failing because setting value doesn't survive","fancy_title":"Strange QUnit behaviour?: test failing because setting value doesn’t survive","slug":"strange-qunit-behaviour-test-failing-because-setting-value-doesnt-survive","posts_count":5,"reply_count":2,"highest_post_number":5,"created_at":"2023-08-07T10:05:22.298Z","last_posted_at":"2023-09-06T11:19:24.509Z","bumped":true,"bumped_at":"2023-08-07T11:24:27.150Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":249167,"title":"Is it possible to override the Site object with own fixture during Front End tests of a Plugin?","fancy_title":"Is it possible to override the Site object with own fixture during Front End tests of a Plugin?","slug":"is-it-possible-to-override-the-site-object-with-own-fixture-during-front-end-tests-of-a-plugin","posts_count":4,"reply_count":0,"highest_post_number":4,"created_at":"2022-12-16T18:32:29.079Z","last_posted_at":"2022-12-28T21:57:56.070Z","bumped":true,"bumped_at":"2022-12-28T21:57:56.070Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":286355,"title":"Acceptance tests failing on Github Actions","fancy_title":"Acceptance tests failing on Github Actions","slug":"acceptance-tests-failing-on-github-actions","posts_count":6,"reply_count":0,"highest_post_number":6,"created_at":"2023-11-22T18:46:46.128Z","last_posted_at":"2023-11-24T13:59:40.100Z","bumped":true,"bumped_at":"2023-11-24T13:59:40.100Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":91312,"title":"There was a problem sending the test email","fancy_title":"There was a problem sending the test email","slug":"there-was-a-problem-sending-the-test-email","posts_count":8,"reply_count":5,"highest_post_number":8,"created_at":"2018-07-01T17:12:48.366Z","last_posted_at":"2018-08-01T09:28:21.935Z","bumped":true,"bumped_at":"2018-07-02T09:28:16.014Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":287022,"title":"Strange migration error in tests during GH workflow","fancy_title":"Strange migration error in tests during GH workflow","slug":"strange-migration-error-in-tests-during-gh-workflow","posts_count":6,"reply_count":3,"highest_post_number":6,"created_at":"2023-11-29T23:18:11.686Z","last_posted_at":"2023-12-31T15:09:05.633Z","bumped":true,"bumped_at":"2023-12-01T15:08:31.257Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":238372,"title":"Smtp doctor test using port 465 even though its configured to use 2525","fancy_title":"Smtp doctor test using port 465 even though its configured to use 2525","slug":"smtp-doctor-test-using-port-465-even-though-its-configured-to-use-2525","posts_count":12,"reply_count":7,"highest_post_number":12,"created_at":"2022-09-07T20:22:10.976Z","last_posted_at":"2022-10-09T00:21:55.656Z","bumped":true,"bumped_at":"2022-09-09T00:21:48.272Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":146326,"title":"Sidekiq not running. Sidekiq heartbeat test failed, restarting","fancy_title":"Sidekiq not running. Sidekiq heartbeat test failed, restarting","slug":"sidekiq-not-running-sidekiq-heartbeat-test-failed-restarting","posts_count":16,"reply_count":9,"highest_post_number":16,"created_at":"2020-03-31T18:51:44.061Z","last_posted_at":"2020-06-10T01:39:28.366Z","bumped":true,"bumped_at":"2020-05-11T01:39:26.054Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":["unsupported-install"],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":255406,"title":"Tests for plugin that requires a plugin","fancy_title":"Tests for plugin that requires a plugin","slug":"tests-for-plugin-that-requires-a-plugin","posts_count":3,"reply_count":0,"highest_post_number":3,"created_at":"2023-02-16T22:12:10.344Z","last_posted_at":"2023-02-28T16:10:36.498Z","bumped":true,"bumped_at":"2023-02-28T20:21:06.122Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":246069,"title":"Need to restart Ember in order to test front-end changes","fancy_title":"Need to restart Ember in order to test front-end changes","slug":"need-to-restart-ember-in-order-to-test-front-end-changes","posts_count":5,"reply_count":1,"highest_post_number":5,"created_at":"2022-11-18T17:03:45.900Z","last_posted_at":"2022-11-18T18:17:53.648Z","bumped":true,"bumped_at":"2022-11-18T18:17:53.648Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":true},{"id":227090,"title":"Bitnami Discourse VM on Virtualbox + SMTP mail-server for testing","fancy_title":"Bitnami Discourse VM on Virtualbox + SMTP mail-server for testing","slug":"bitnami-discourse-vm-on-virtualbox-smtp-mail-server-for-testing","posts_count":3,"reply_count":0,"highest_post_number":3,"created_at":"2022-05-15T18:02:33.136Z","last_posted_at":"2022-05-16T09:10:43.794Z","bumped":true,"bumped_at":"2022-05-16T09:10:43.794Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":["unsupported-install"],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":145781,"title":"Test emails sent but","fancy_title":"Test emails sent but","slug":"test-emails-sent-but","posts_count":5,"reply_count":2,"highest_post_number":5,"created_at":"2020-03-27T07:14:10.116Z","last_posted_at":"2020-04-29T02:52:31.927Z","bumped":true,"bumped_at":"2020-03-30T02:52:29.062Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":137496,"title":"Sidekiq heartbeat test failed, restarting","fancy_title":"Sidekiq heartbeat test failed, restarting","slug":"sidekiq-heartbeat-test-failed-restarting","posts_count":13,"reply_count":8,"highest_post_number":13,"created_at":"2020-01-01T11:20:33.492Z","last_posted_at":"2020-02-11T23:09:42.375Z","bumped":true,"bumped_at":"2020-01-12T23:09:39.730Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":212684,"title":"MailGun & Discourse: Sandbox subdomains are for test purposes only. Please add your own domain","fancy_title":"MailGun & Discourse: Sandbox subdomains are for test purposes only. Please add your own domain","slug":"mailgun-discourse-sandbox-subdomains-are-for-test-purposes-only-please-add-your-own-domain","posts_count":4,"reply_count":2,"highest_post_number":5,"created_at":"2021-12-20T13:52:10.405Z","last_posted_at":"2022-01-19T15:27:11.368Z","bumped":true,"bumped_at":"2021-12-20T15:26:24.911Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":102035,"title":"Test admin features without having to install Discourse","fancy_title":"Test admin features without having to install Discourse","slug":"test-admin-features-without-having-to-install-discourse","posts_count":6,"reply_count":2,"highest_post_number":6,"created_at":"2018-11-14T15:20:59.484Z","last_posted_at":"2021-09-09T07:35:18.270Z","bumped":true,"bumped_at":"2021-09-09T07:35:18.270Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":121299,"title":"Install discourse with a staging (test) ssl certificate","fancy_title":"Install discourse with a staging (test) ssl certificate","slug":"install-discourse-with-a-staging-test-ssl-certificate","posts_count":5,"reply_count":1,"highest_post_number":5,"created_at":"2019-06-25T17:12:34.552Z","last_posted_at":"2023-04-01T03:25:32.925Z","bumped":true,"bumped_at":"2019-09-04T15:15:31.153Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":true},{"id":118568,"title":"Problem testing Badge Query from Data Explorer","fancy_title":"Problem testing Badge Query from Data Explorer","slug":"problem-testing-badge-query-from-data-explorer","posts_count":5,"reply_count":1,"highest_post_number":6,"created_at":"2019-05-24T00:14:35.814Z","last_posted_at":"2019-05-24T00:46:42.560Z","bumped":true,"bumped_at":"2019-05-24T00:46:42.560Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":true,"archived":false,"bookmarked":null,"liked":null,"tags":["sql-triggered-badge"],"tags_descriptions":{"sql-triggered-badge":"SQL queries for custom triggered badges"},"category_id":148,"has_accepted_answer":true},{"id":115912,"title":"New iOS mobile app beta available for testing","fancy_title":"New iOS mobile app beta available for testing","slug":"new-ios-mobile-app-beta-available-for-testing","posts_count":49,"reply_count":31,"highest_post_number":49,"created_at":"2019-04-25T01:24:56.608Z","last_posted_at":"2019-05-31T17:31:02.516Z","bumped":true,"bumped_at":"2020-01-21T17:07:37.817Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":224560,"title":"502 Bad Gateway after trying to rebuild test-passed branch","fancy_title":"502 Bad Gateway after trying to rebuild test-passed branch","slug":"502-bad-gateway-after-trying-to-rebuild-test-passed-branch","posts_count":6,"reply_count":1,"highest_post_number":6,"created_at":"2022-04-17T21:46:04.598Z","last_posted_at":"2022-04-17T22:15:37.255Z","bumped":true,"bumped_at":"2022-04-17T22:15:37.255Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":false},{"id":173019,"title":"Forum offline due to failed rebuilds on Tests-Pass","fancy_title":"Forum offline due to failed rebuilds on Tests-Pass","slug":"forum-offline-due-to-failed-rebuilds-on-tests-pass","posts_count":2,"reply_count":0,"highest_post_number":2,"created_at":"2020-12-11T18:44:17.952Z","last_posted_at":"2020-12-11T19:12:11.141Z","bumped":true,"bumped_at":"2020-12-11T19:12:11.141Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":31,"has_accepted_answer":false},{"id":196501,"title":"[A/B Testing] Changing parent CSS class based on experiment variable","fancy_title":"[A/B Testing] Changing parent CSS class based on experiment variable","slug":"a-b-testing-changing-parent-css-class-based-on-experiment-variable","posts_count":3,"reply_count":1,"highest_post_number":3,"created_at":"2021-07-08T19:58:55.503Z","last_posted_at":"2021-07-15T18:19:21.092Z","bumped":true,"bumped_at":"2021-07-15T18:19:21.092Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":282856,"title":"Code-level performance testing","fancy_title":"Code-level performance testing","slug":"code-level-performance-testing","posts_count":2,"reply_count":0,"highest_post_number":2,"created_at":"2023-10-20T03:44:49.568Z","last_posted_at":"2023-10-23T23:29:59.741Z","bumped":true,"bumped_at":"2023-10-23T23:29:59.741Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":272112,"title":"Running core tests in docker environment","fancy_title":"Running core tests in docker environment","slug":"running-core-tests-in-docker-environment","posts_count":1,"reply_count":0,"highest_post_number":1,"created_at":"2023-07-19T12:45:57.266Z","last_posted_at":"2023-07-19T12:45:57.446Z","bumped":true,"bumped_at":"2023-07-19T12:45:57.446Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["docker","spec","testing"],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":87295,"title":"Email test from the console?","fancy_title":"Email test from the console?","slug":"email-test-from-the-console","posts_count":4,"reply_count":1,"highest_post_number":4,"created_at":"2018-05-11T22:03:23.101Z","last_posted_at":"2018-05-12T00:55:41.843Z","bumped":true,"bumped_at":"2018-05-12T00:55:41.843Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":59577,"title":"Plugin QUnit tests are not running as part of rake qunit:test","fancy_title":"Plugin QUnit tests are not running as part of rake qunit:test","slug":"plugin-qunit-tests-are-not-running-as-part-of-rake-qunit-test","posts_count":8,"reply_count":5,"highest_post_number":8,"created_at":"2017-03-20T20:29:06.536Z","last_posted_at":"2017-07-17T18:26:45.188Z","bumped":true,"bumped_at":"2017-07-17T18:26:45.188Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":181856,"title":"How does the Discourse team do usability testing?","fancy_title":"How does the Discourse team do usability testing?","slug":"how-does-the-discourse-team-do-usability-testing","posts_count":5,"reply_count":2,"highest_post_number":6,"created_at":"2021-03-03T18:09:29.357Z","last_posted_at":"2021-03-04T15:23:46.571Z","bumped":true,"bumped_at":"2021-03-04T15:23:46.571Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":3,"has_accepted_answer":false},{"id":108110,"title":"Advice on writing Ruby tests for plugins","fancy_title":"Advice on writing Ruby tests for plugins","slug":"advice-on-writing-ruby-tests-for-plugins","posts_count":15,"reply_count":13,"highest_post_number":15,"created_at":"2019-02-01T09:52:27.051Z","last_posted_at":"2019-05-03T03:54:47.163Z","bumped":true,"bumped_at":"2019-05-03T03:54:47.163Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":57292,"title":"Acceptance test content is present on page","fancy_title":"Acceptance test content is present on page","slug":"acceptance-test-content-is-present-on-page","posts_count":7,"reply_count":3,"highest_post_number":7,"created_at":"2017-02-13T02:48:10.108Z","last_posted_at":"2017-02-14T05:55:31.520Z","bumped":true,"bumped_at":"2017-02-14T05:55:31.520Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":291039,"title":"Google Search Console/Schema Markup test tool \"Google Rich Results Test\": mobile-view instead of crawler-view","fancy_title":"Google Search Console/Schema Markup test tool “Google Rich Results Test”: mobile-view instead of crawler-view","slug":"google-search-console-schema-markup-test-tool-google-rich-results-test-mobile-view-instead-of-crawler-view","posts_count":3,"reply_count":1,"highest_post_number":3,"created_at":"2024-01-09T16:53:57.797Z","last_posted_at":"2024-01-09T17:17:09.039Z","bumped":true,"bumped_at":"2024-01-09T17:17:09.039Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":1,"has_accepted_answer":false},{"id":267737,"title":"Experiments with Model-Based Testing","fancy_title":"Experiments with Model-Based Testing","slug":"experiments-with-model-based-testing","posts_count":1,"reply_count":0,"highest_post_number":1,"created_at":"2023-06-08T22:35:51.784Z","last_posted_at":"2023-06-08T22:35:51.914Z","bumped":true,"bumped_at":"2023-06-08T22:35:51.914Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":233389,"title":"Qunit tests not deterministic in Plugin?","fancy_title":"Qunit tests not deterministic in Plugin?","slug":"qunit-tests-not-deterministic-in-plugin","posts_count":2,"reply_count":0,"highest_post_number":2,"created_at":"2022-07-20T08:48:28.757Z","last_posted_at":"2022-07-20T11:03:42.522Z","bumped":true,"bumped_at":"2022-07-20T11:03:42.522Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":135876,"title":"Issues migrating test database","fancy_title":"Issues migrating test database","slug":"issues-migrating-test-database","posts_count":9,"reply_count":1,"highest_post_number":9,"created_at":"2019-12-12T21:01:31.303Z","last_posted_at":"2021-03-02T16:44:20.120Z","bumped":true,"bumped_at":"2021-03-02T16:44:20.120Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":175413,"title":"Acceptance Test for Markdown Extension?","fancy_title":"Acceptance Test for Markdown Extension?","slug":"acceptance-test-for-markdown-extension","posts_count":3,"reply_count":0,"highest_post_number":4,"created_at":"2021-01-07T19:56:04.931Z","last_posted_at":"2021-01-10T11:26:16.888Z","bumped":true,"bumped_at":"2021-01-10T16:30:56.164Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":150786,"title":"Auto migrate test database schema","fancy_title":"Auto migrate test database schema","slug":"auto-migrate-test-database-schema","posts_count":2,"reply_count":0,"highest_post_number":2,"created_at":"2020-05-08T04:27:56.739Z","last_posted_at":"2020-05-08T13:15:45.247Z","bumped":true,"bumped_at":"2020-05-08T13:15:45.247Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":118639,"title":"Building Tests for New discourse_api Endpoints","fancy_title":"Building Tests for New discourse_api Endpoints","slug":"building-tests-for-new-discourse-api-endpoints","posts_count":20,"reply_count":9,"highest_post_number":21,"created_at":"2019-05-24T22:21:24.896Z","last_posted_at":"2019-10-02T21:13:35.140Z","bumped":true,"bumped_at":"2019-10-02T21:36:26.639Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":126025,"title":"Seed or API calls to create test users","fancy_title":"Seed or API calls to create test users","slug":"seed-or-api-calls-to-create-test-users","posts_count":7,"reply_count":5,"highest_post_number":7,"created_at":"2019-08-17T02:13:06.578Z","last_posted_at":"2019-08-19T11:50:36.137Z","bumped":true,"bumped_at":"2019-08-19T11:50:36.137Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":71118,"title":"Smoke-testing plugins during upgrade process","fancy_title":"Smoke-testing plugins during upgrade process","slug":"smoke-testing-plugins-during-upgrade-process","posts_count":22,"reply_count":17,"highest_post_number":22,"created_at":"2017-10-01T08:18:48.067Z","last_posted_at":"2017-10-02T23:44:59.852Z","bumped":true,"bumped_at":"2017-10-02T23:44:59.852Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":["pr-welcome"],"tags_descriptions":{"pr-welcome":"You're welcome to submit a Github pull request that implements this"},"category_id":2,"has_accepted_answer":false},{"id":62797,"title":"QUnit tests won't pass in discourse_dev docker image","fancy_title":"QUnit tests won’t pass in discourse_dev docker image","slug":"qunit-tests-wont-pass-in-discourse-dev-docker-image","posts_count":20,"reply_count":11,"highest_post_number":21,"created_at":"2017-05-16T10:28:58.397Z","last_posted_at":"2017-07-25T15:50:27.716Z","bumped":true,"bumped_at":"2017-07-25T15:50:27.716Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":42947,"title":"Testing a Discourse API client on Travis-CI","fancy_title":"Testing a Discourse API client on Travis-CI","slug":"testing-a-discourse-api-client-on-travis-ci","posts_count":5,"reply_count":3,"highest_post_number":5,"created_at":"2016-04-21T19:15:00.130Z","last_posted_at":"2016-04-22T03:03:47.976Z","bumped":true,"bumped_at":"2016-04-22T03:03:47.976Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false},{"id":197887,"title":"PostMenu's '_extraButtons' isn't reset in between acceptance tests","fancy_title":"PostMenu’s ‘_extraButtons’ isn’t reset in between acceptance tests","slug":"postmenus-extrabuttons-isnt-reset-in-between-acceptance-tests","posts_count":2,"reply_count":0,"highest_post_number":4,"created_at":"2021-07-22T16:57:05.803Z","last_posted_at":"2021-08-04T09:11:29.538Z","bumped":true,"bumped_at":"2021-08-04T09:11:29.538Z","archetype":"regular","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"tags":[],"tags_descriptions":{},"category_id":7,"has_accepted_answer":false}],"users":[],"categories":[],"tags":[],"groups":[],"grouped_search_result":{"more_posts":null,"more_users":null,"more_categories":null,"term":"testing","search_log_id":2089836,"more_full_page_results":true,"can_create_topic":false,"error":null,"post_ids":[218354,138484,1381521,311252,59431,1419473,722424,266441,582008,1421414,1204506,1339808,1211823,1408389,443129,1412219,1160803,725228,1240758,1197679,1103913,722608,679950,1033333,498559,596557,583541,569209,1090520,860593,960683,1389188,1329017,421687,272436,899205,530438,260691,1432567,1305996,1135474,672801,871105,747613,583961,621707,334186,288991,187533,966756],"user_ids":[],"category_ids":[],"tag_ids":[],"group_ids":[],"extra":{"categories":[{"id":67,"name":"announcements","color":"ED207B","text_color":"FFFFFF","slug":"announcements","topic_count":312,"post_count":3973,"position":0,"description":"The place for all Discourse announcements.","description_text":"The place for all Discourse announcements.","description_excerpt":"The place for all Discourse announcements.","topic_url":"/t/about-the-announcements-category/68629","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":true,"sort_order":null,"sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_ready":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_enabled":true,"activity_pub_username":"announcements","activity_pub_name":"Announcements","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":["new-feature","security"],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":true,"activity_pub_ready":true,"activity_pub_actor":{"id":2,"handle":"announcements@meta.discourse.org","name":"Announcements"},"activity_pub_username":"announcements","activity_pub_name":"Announcements","activity_pub_default_visibility":"public","activity_pub_publication_type":"first_post","activity_pub_post_object_type":"Note","uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":13,"name":"blog","color":"8080ff","text_color":"FFFFFF","slug":"blog","topic_count":170,"post_count":1477,"position":1,"description":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","description_text":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","description_excerpt":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","topic_url":"/t/about-the-blog-category/5250","read_restricted":false,"permission":null,"parent_category_id":67,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"created","sort_ascending":false,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":10,"name":"documentation","color":"00A94F","text_color":"FFFFFF","slug":"documentation","topic_count":2,"post_count":2,"position":2,"description":"Documentation for how to use Discourse, install and configure sites, and troubleshoot common issues. Includes topics for tutorials, how-tos, general reference and troubleshooting. Topics need to be created in one of the subcategories, and may only be created by trust level 2 and up.","description_text":"Documentation for how to use Discourse, install and configure sites, and troubleshoot common issues. Includes topics for tutorials, how-tos, general reference and troubleshooting. Topics need to be created in one of the subcategories, and may only be created by trust level 2 and up.","description_excerpt":"Documentation for how to use Discourse, install and configure sites, and troubleshoot common issues. Includes topics for tutorials, how-tos, general reference and troubleshooting. Topics need to be created in one of the subcategories, and may only be created by trust level 2 and up.","topic_url":"/t/about-the-documentation-category/2629","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"top","subcategory_list_style":"boxes","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_ready":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Article","activity_pub_publication_type":"full_topic","activity_pub_username":"documentation","activity_pub_enabled":true,"activity_pub_name":"Documentation"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":true,"activity_pub_ready":true,"activity_pub_actor":{"id":31769,"handle":"documentation@meta.discourse.org","name":"Documentation"},"activity_pub_username":"documentation","activity_pub_name":"Documentation","activity_pub_default_visibility":"public","activity_pub_publication_type":"full_topic","activity_pub_post_object_type":"Article","uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":53,"name":"admins","color":"F15D22","text_color":"FFFFFF","slug":"admins","topic_count":222,"post_count":1703,"position":3,"description":"Guides for Discourse admins and community managers with admin access.","description_text":"Guides for Discourse admins and community managers with admin access.","description_excerpt":"Guides for Discourse admins and community managers with admin access.","topic_url":"/t/about-the-admins-category/56207","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":["hosted-support","migrations"],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":125,"name":"moderators","color":"40d0e2","text_color":"FFFFFF","slug":"moderators","topic_count":16,"post_count":71,"position":4,"description":"Documentation for moderators and community managers using Discourse.","description_text":"Documentation for moderators and community managers using Discourse.","description_excerpt":"Documentation for moderators and community managers using Discourse.","topic_url":"/t/about-the-moderators-category/238588","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":126,"name":"users","color":"0088CC","text_color":"FFFFFF","slug":"users","topic_count":53,"post_count":184,"position":5,"description":"Documentation for all members of communities running Discourse.","description_text":"Documentation for all members of communities running Discourse.","description_excerpt":"Documentation for all members of communities running Discourse.","topic_url":"/t/about-the-users-category/238917","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":55,"name":"sysadmin","color":"E9DD00","text_color":"FFFFFF","slug":"sysadmin","topic_count":175,"post_count":2803,"position":6,"description":"Documentation for self-hosters and Discourse system administrators.","description_text":"Documentation for self-hosters and Discourse system administrators.","description_excerpt":"Documentation for self-hosters and Discourse system administrators.","topic_url":"/t/about-the-sysadmin-category/56209","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":["migrations"],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":127,"name":"theme developers","color":"92278F","text_color":"FFFFFF","slug":"theme-developers","topic_count":41,"post_count":215,"position":7,"description":"Documentation for developing themes and components that can be installed by admins.","description_text":"Documentation for developing themes and components that can be installed by admins.","description_excerpt":"Documentation for developing themes and components that can be installed by admins.","topic_url":"/t/about-the-theme-developers-category/239285","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":56,"name":"developers","color":"00A94F","text_color":"FFFFFF","slug":"devs","topic_count":96,"post_count":1185,"position":8,"description":"Documentation for developing features, plugins, or integrations with Discourse.","description_text":"Documentation for developing features, plugins, or integrations with Discourse.","description_excerpt":"Documentation for developing features, plugins, or integrations with Discourse.","topic_url":"/t/about-the-developers-category/56210","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_ready":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Article","activity_pub_enabled":true,"activity_pub_username":"developer-docs","activity_pub_name":"Developer Documentation","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":true,"activity_pub_ready":true,"activity_pub_actor":{"id":35545,"handle":"developer-docs@meta.discourse.org","name":"Developer Documentation"},"activity_pub_username":"developer-docs","activity_pub_name":"Developer Documentation","activity_pub_default_visibility":"public","activity_pub_publication_type":"first_post","activity_pub_post_object_type":"Article","uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":6,"name":"support","color":"CEA9A9","text_color":"FFFFFF","slug":"support","topic_count":15841,"post_count":103687,"position":9,"description":"The category for general support questions on using your Discourse site.","description_text":"The category for general support questions on using your Discourse site.","description_excerpt":"The category for general support questions on using your Discourse site.","topic_url":"/t/about-the-support-category/389","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"\u003e Before asking, did you search first? Press 🔍 at the upper right to search.","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_accepted_answers":"true","enable_unassigned_filter":"false","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":21,"name":"wordpress","color":"F9CFCF","text_color":"FFFFFF","slug":"wordpress","topic_count":732,"post_count":5110,"position":10,"description":"Support for the official Discourse WordPress plugin at \u003ca href=\"https://github.com/discourse/wp-discourse\" class=\"inline-onebox\"\u003eGitHub - discourse/wp-discourse: WordPress plugin that lets you use Discourse as the community engine for a WordPress blog\u003c/a\u003e","description_text":"Support for the official Discourse WordPress plugin at GitHub - discourse/wp-discourse: WordPress plugin that lets you use Discourse as the community engine for a WordPress blog","description_excerpt":"Support for the official Discourse WordPress plugin at \u003ca href=\"https://github.com/discourse/wp-discourse\" class=\"inline-onebox\"\u003eGitHub - discourse/wp-discourse: WordPress plugin that lets you use Discourse as the community engine for a WordPress blog\u003c/a\u003e","topic_url":"/t/about-the-wordpress-category/12282","read_restricted":false,"permission":null,"parent_category_id":6,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":1,"name":"bug","color":"e9dd00","text_color":"000000","slug":"bug","topic_count":5153,"post_count":35973,"position":11,"description":"A bug report means \u003cstrong\u003esomething is broken, preventing normal/typical use of Discourse\u003c/strong\u003e. Do be sure to search prior to submitting bugs. \u003ca href=\"https://meta.discourse.org/t/how-to-write-a-good-bug-report/183671/\"\u003eInclude repro steps\u003c/a\u003e, and only describe one bug per topic please.","description_text":"A bug report means something is broken, preventing normal/typical use of Discourse. Do be sure to search prior to submitting bugs. Include repro steps, and only describe one bug per topic please.","description_excerpt":"A bug report means something is broken, preventing normal/typical use of Discourse. Do be sure to search prior to submitting bugs. \u003ca href=\"https://meta.discourse.org/t/how-to-write-a-good-bug-report/183671/\"\u003eInclude repro steps\u003c/a\u003e, and only describe one bug per topic please.","topic_url":"/t/about-the-bug-category/2","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_accepted_answers":null,"enable_unassigned_filter":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":9,"name":"ux","color":"5F497A","text_color":"FFFFFF","slug":"ux","topic_count":2700,"post_count":18225,"position":12,"description":"Discussion about the user interface of Discourse and how features are presented (including language and UI elements).","description_text":"Discussion about the user interface of Discourse and how features are presented (including language and UI elements).","description_excerpt":"Discussion about the user interface of Discourse and how features are presented (including language and UI elements).","topic_url":"/t/about-the-ux-category/2628","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":2,"name":"feature","color":"0E76BD","text_color":"FFFFFF","slug":"feature","topic_count":6997,"post_count":58067,"position":13,"description":"Discussion about existing Discourse features, how they can be improved or enhanced, and how proposed new features could work.","description_text":"Discussion about existing Discourse features, how they can be improved or enhanced, and how proposed new features could work.","description_excerpt":"Discussion about existing Discourse features, how they can be improved or enhanced, and how proposed new features could work.","topic_url":"/t/about-the-feature-category/11","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_ready":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_username":"feature","activity_pub_name":"Feature Requests","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post","activity_pub_enabled":true},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":true,"activity_pub_ready":true,"activity_pub_actor":{"id":1,"handle":"feature@meta.discourse.org","name":"Feature Requests"},"activity_pub_username":"feature","activity_pub_name":"Feature Requests","activity_pub_default_visibility":"public","activity_pub_publication_type":"first_post","activity_pub_post_object_type":"Note","uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":148,"name":"data \u0026 reporting","color":"D24899","text_color":"FFFFFF","slug":"data-reporting","topic_count":631,"post_count":3056,"position":14,"description":"This is the category for everything related to Discourse data and reporting. Here, you can discuss dashboard reports, custom badge queries, data-explorer queries and any other analytic add-ons.","description_text":"This is the category for everything related to Discourse data and reporting. Here, you can discuss dashboard reports, custom badge queries, data-explorer queries and any other analytic add-ons.","description_excerpt":"This is the category for everything related to Discourse data and reporting. Here, you can discuss dashboard reports, custom badge queries, data-explorer queries and any other analytic add-ons.","topic_url":"/t/about-the-data-reporting-category/274664","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","enable_accepted_answers":"true","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":24,"name":"sso","color":"d47711","text_color":"FFFFFF","slug":"sso","topic_count":493,"post_count":2546,"position":15,"description":"For queries specifically about SSO (single sign-on) and login using third-party providers (Google, Facebook, GitHub etc). See the \u003ca href=\"https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045\"\u003eofficial documentation on DiscourseConnect SSO\u003c/a\u003e.","description_text":"For queries specifically about SSO (single sign-on) and login using third-party providers (Google, Facebook, GitHub etc). See the official documentation on DiscourseConnect SSO.","description_excerpt":"For queries specifically about SSO (single sign-on) and login using third-party providers (Google, Facebook, GitHub etc). See the \u003ca href=\"https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045\"\u003eofficial documentation on DiscourseConnect SSO\u003c/a\u003e.","topic_url":"/t/about-the-sso-category/13110","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":65,"name":"community","color":"12A89D","text_color":"FFFFFF","slug":"community","topic_count":866,"post_count":8844,"position":16,"description":"A great platform doesn’t guarantee success. Community building is a science. This category is for discussions about launching, building, growing and managing a thriving community.","description_text":"A great platform doesn’t guarantee success. Community building is a science. This category is for discussions about launching, building, growing and managing a thriving community.","description_excerpt":"A great platform doesn’t guarantee success. Community building is a science. This category is for discussions about launching, building, growing and managing a thriving community.","topic_url":"/t/about-the-community-category/67750","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":7,"name":"dev","color":"292929","text_color":"fff","slug":"dev","topic_count":3467,"post_count":20210,"position":17,"description":"The category for all things Discourse Development. Building a customization for yourself or the community? Then this is the category for you!","description_text":"The category for all things Discourse Development. Building a customization for yourself or the community? Then this is the category for you!","description_excerpt":"The category for all things Discourse Development. Building a customization for yourself or the community? Then this is the category for you!","topic_url":"/t/about-the-dev-category/1026","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"boxes","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":27,"name":"translations","color":"808281","text_color":"FFFFFF","slug":"translations","topic_count":297,"post_count":1832,"position":18,"description":"This category is for discussion about localizing Discourse.","description_text":"This category is for discussion about localizing Discourse.","description_excerpt":"This category is for discussion about localizing Discourse.","topic_url":"/t/about-the-translations-category/14549","read_restricted":false,"permission":null,"parent_category_id":7,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":14,"name":"marketplace","color":"8C6238","text_color":"FFFFFF","slug":"marketplace","topic_count":1162,"post_count":5865,"position":19,"description":"This is your hub for all Discourse-related commerce: jobs, gigs, plugins, themes, hosting, and more.","description_text":"This is your hub for all Discourse-related commerce: jobs, gigs, plugins, themes, hosting, and more.","description_excerpt":"This is your hub for all Discourse-related commerce: jobs, gigs, plugins, themes, hosting, and more.","topic_url":"/t/about-the-marketplace-category/5425","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"What would you like done?\n\nWhen do you need it done?\n\nWhat is your budget, in $ USD that you can offer for this task?\n\n\u003c!-- We encourage caution and due diligence when engaging with potential contractors or clients. Verify their credentials, check previous work, and ensure a transparent and legitimate transaction. Always remember, your safety and security in the marketplace is your responsibility. --\u003e","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":["delivered"],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":22,"name":"plugin","color":"F7941D","text_color":"FFFFFF","slug":"plugin","topic_count":315,"post_count":10429,"position":20,"description":"A directory of Discourse plugins, both official and third-party.","description_text":"A directory of Discourse plugins, both official and third-party.","description_excerpt":"A directory of Discourse plugins, both official and third-party.","topic_url":"/t/about-the-plugin-category/12648","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"| | | |\n| - | - | - |\n| :information_source: | **Summary** | In a few words, what does this plugin do? |\n| :hammer_and_wrench: | **Repository Link** | \u003c\u003e |\n| :open_book: | **Install Guide** | [How to install plugins in Discourse](https://meta.discourse.org/t/install-plugins-in-discourse/19157) |\n\n\u003cbr\u003e \n\n### Features\n \nDescribe the major features of the plugin\n \n### Configuration\n \nInclude detailed steps on how to configure the plugin (include screenshots where necessary)\n \n### CHANGELOG\n- Add new bullets when major features are committed here\n \n### TODO\n- Add any #pr-welcome TODO tasks here","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"boxes","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":["Resource Status"],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":5,"name":"extras","color":"25AAE2","text_color":"FFFFFF","slug":"extras","topic_count":90,"post_count":985,"position":21,"description":"A directory of all extensions \u0026amp; integrations for Discourse which are \u003cem\u003enot\u003c/em\u003e Discourse plugins, i.e. a CMS plugin, a browser extension or a native application.","description_text":"A directory of all extensions \u0026amp; integrations for Discourse which are not Discourse plugins, i.e. a CMS plugin, a browser extension or a native application.","description_excerpt":"A directory of all extensions \u0026amp; integrations for Discourse which are not Discourse plugins, i.e. a CMS plugin, a browser extension or a native application.","topic_url":"/t/about-the-extras-category/28","read_restricted":false,"permission":null,"parent_category_id":22,"notification_level":1,"topic_template":"","has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":["Resource Status"],"allow_global_tags":true,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":61,"name":"theme","color":"E43D30","text_color":"FFFFFF","slug":"theme","topic_count":66,"post_count":2138,"position":22,"description":"Themes are expansive customizations that change multiple elements of the style of your forum design, and often also include additional front-end features.","description_text":"Themes are expansive customizations that change multiple elements of the style of your forum design, and often also include additional front-end features.","description_excerpt":"Themes are expansive customizations that change multiple elements of the style of your forum design, and often also include additional front-end features.","topic_url":"/t/about-the-theme-category/60925","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"||||\n|-|-|-|\n| :information_source: | **Summary** | ADD SHORT SUMMARY \n| :eyeglasses:|**Preview**| PREVIEW_LINK |\n| :hammer_and_wrench:|**Repository**| REPOSITORY_LINK |\n| :question:|**Install Guide**|[How to install a theme or theme component](https://meta.discourse.org/t/how-do-i-install-a-theme-or-theme-component/63682)|\n| :open_book:|**New to Discourse Themes?**| [Beginner’s guide to using Discourse Themes](https://meta.discourse.org/t/beginners-guide-to-using-discourse-themes/91966)\n\n\u003c!-- Describe this theme in one or two sentences --\u003e\n\nShort description...\n\n\u003c!-- Add screenshots (if applicable) --\u003e\n\nScreenshots...\n\n\u003c!-- Add more details and explain the settings (if applicable) --\u003e\n\nDetailed description...\n","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"none","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":["color-palette"],"allowed_tag_groups":["Resource Status"],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":120,"name":"theme-component","color":"1dedf8","text_color":"FFFFFF","slug":"theme-component","topic_count":300,"post_count":7516,"position":23,"description":"Theme components are customizations that change surface elements of your forum design, or add extra front-end features.","description_text":"Theme components are customizations that change surface elements of your forum design, or add extra front-end features.","description_excerpt":"Theme components are customizations that change surface elements of your forum design, or add extra front-end features.","topic_url":"/t/about-the-theme-component-category/232731","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"| | | |\n| - | - | - |\n| :information_source: | **Summary** | ADD SHORT SUMMARY |\n| :eyeglasses: |**Preview**| PREVIEW_LINK |\n| :hammer_and_wrench: | **Repository**| REPOSITORY_LINK |\n| :question: | **Install Guide** | [How to install a theme or theme component](https://meta.discourse.org/t/how-do-i-install-a-theme-or-theme-component/63682) |\n| :open_book: | **New to Discourse Themes?** | [Beginner’s guide to using Discourse Themes](https://meta.discourse.org/t/beginners-guide-to-using-discourse-themes/91966) |\n\n\u003c!-- Fill in \"repoName\" and \"repoURL\" for the automatic install button --\u003e\n\n[wrap=theme-install-button repoName=\"Component's name\" repoUrl=\"GitHub repository link\"]\nInstall this theme component\n[/wrap]\n\n\u003c!-- Describe this theme/component in one or two sentences --\u003e\n\nShort description...\n\n\u003c!-- Add screenshots (if applicable) --\u003e\n\nScreenshots...\n\n\u003c!-- Add more details and explain the settings (if applicable) --\u003e\n\nDetailed description...","has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"none","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":["Resource Status"],"allow_global_tags":true,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":31,"name":"installation","color":"997E7E","text_color":"FFFFFF","slug":"installation","topic_count":3426,"post_count":29611,"position":24,"description":"Getting Discourse up and running, keeping it going, upgrading, and any other general sysadmin maintenance.","description_text":"Getting Discourse up and running, keeping it going, upgrading, and any other general sysadmin maintenance.","description_excerpt":"Getting Discourse up and running, keeping it going, upgrading, and any other general sysadmin maintenance.","topic_url":"/t/about-the-installation-category/21019","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":106,"name":"migration","color":"652D90","text_color":"FFFFFF","slug":"migration","topic_count":215,"post_count":1558,"position":25,"description":"You want to migrate your community to Discourse? Awesome! This is the category where you can ask questions, get help, and document your Discourse migration journey.","description_text":"You want to migrate your community to Discourse? Awesome! This is the category where you can ask questions, get help, and document your Discourse migration journey.","description_excerpt":"You want to migrate your community to Discourse? Awesome! This is the category where you can ask questions, get help, and document your Discourse migration journey.","topic_url":"/t/about-the-migration-category/196969","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":["Migration"],"allow_global_tags":true,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":8,"name":"hosting","color":"00AEEF","text_color":"FFFFFF","slug":"hosting","topic_count":501,"post_count":4117,"position":26,"description":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","description_text":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","description_excerpt":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","topic_url":"/t/about-the-hosting-category/2626","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post","enable_accepted_answers":"true"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":30,"name":"releases","color":"BF1E2E","text_color":"FFFFFF","slug":"releases","topic_count":23,"post_count":104,"position":27,"description":"Outlining each official release of Discourse, and plans for future releases.","description_text":"Outlining each official release of Discourse, and plans for future releases.","description_excerpt":"Outlining each official release of Discourse, and plans for future releases.","topic_url":"/t/about-the-releases-category/20857","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"created","sort_ascending":false,"show_subcategory_list":true,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":3,"name":"site feedback","color":"888","text_color":"FFFFFF","slug":"site-feedback","topic_count":417,"post_count":3220,"position":28,"description":"Discussion about \u003ca href=\"http://meta.discourse.org\"\u003emeta.discourse.org\u003c/a\u003e itself - the organization of this forum, how it works, and how we can improve this site.","description_text":"Discussion about meta.discourse.org itself - the organization of this forum, how it works, and how we can improve this site.","description_excerpt":"Discussion about \u003ca href=\"http://meta.discourse.org\"\u003emeta.discourse.org\u003c/a\u003e itself - the organization of this forum, how it works, and how we can improve this site.","topic_url":"/t/about-the-site-feedback-category/24","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":152,"name":"theme feedback","color":"ED207B","text_color":"FFFFFF","slug":"theme-feedback","topic_count":11,"post_count":44,"position":29,"description":"This is the category to gather all the UX reports for \u003ca href=\"https://meta.discourse.org/t/we-have-a-new-default-theme-here-on-meta/284692\"\u003eour new theme on meta\u003c/a\u003e. It also uses the new Form Templates feature that I’ve been wanting to try out.","description_text":"This is the category to gather all the UX reports for our new theme on meta. It also uses the new Form Templates feature that I’ve been wanting to try out.","description_excerpt":"This is the category to gather all the UX reports for \u003ca href=\"https://meta.discourse.org/t/we-have-a-new-default-theme-here-on-meta/284692\"\u003eour new theme on meta\u003c/a\u003e. It also uses the new Form Templates feature that I’ve been wanting to try out.","topic_url":"/t/about-the-theme-feedback-category/284904","read_restricted":false,"permission":null,"parent_category_id":3,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[1],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":157,"name":"forum summaries","color":"72e9a7","text_color":"FFFFFF","slug":"forum-summaries","topic_count":4,"post_count":36,"position":30,"description":"Stay up-to-date with the pulse of our community through AI-crafted summaries. This category harnesses the power of artificial intelligence to collate and condense forum activities, providing you with comprehensive yet succinct overviews. From emerging discussions to trending topics, our AI summaries deliver the essence of the Discourse community’s heartbeat, right at your fingertips.","description_text":"Stay up-to-date with the pulse of our community through AI-crafted summaries. This category harnesses the power of artificial intelligence to collate and condense forum activities, providing you with comprehensive yet succinct overviews. From emerging discussions to trending topics, our AI summaries deliver the essence of the Discourse community’s heartbeat, right at your fingertips.","description_excerpt":"Stay up-to-date with the pulse of our community through AI-crafted summaries. This category harnesses the power of artificial intelligence to collate and condense forum activities, providing you with comprehensive yet succinct overviews. From emerging discussions to trending topics, our AI summarie\u0026hellip;","topic_url":"/t/about-the-forum-summaries-category/291765","read_restricted":false,"permission":null,"parent_category_id":3,"notification_level":0,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":35,"name":"praise","color":"9EB83B","text_color":"FFFFFF","slug":"praise","topic_count":294,"post_count":1091,"position":31,"description":"Have something nice to say about Discourse?","description_text":"Have something nice to say about Discourse?","description_excerpt":"Have something nice to say about Discourse?","topic_url":"/t/about-the-praise-category/30010","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":true,"sort_order":null,"sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":63,"name":"comparison","color":"F1592A","text_color":"FFFFFF","slug":"comparison","topic_count":11,"post_count":137,"position":32,"description":"Topics comparing Discourse to other platforms.","description_text":"Topics comparing Discourse to other platforms.","description_excerpt":"Topics comparing Discourse to other platforms.","topic_url":"/t/about-the-comparison-category/65736","read_restricted":false,"permission":null,"parent_category_id":35,"notification_level":1,"topic_template":"### About PLATFORM_NAME\n\nA blurb about the platform being compared to Discourse. \n\nhttp://link.to.website\n\n ### Previous discussions:\n\nlinks to previous discussions related\n\n### Importer status\n\nIs there an official Discourse importer? Where is it? ","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":105,"name":"community support program","color":"92278F","text_color":"FFFFFF","slug":"support-program","topic_count":4,"post_count":27,"position":33,"description":"Get recognition for your work in helping and supporting Discourse communities by joining our Community Support Program.","description_text":"Get recognition for your work in helping and supporting Discourse communities by joining our Community Support Program.","description_excerpt":"Get recognition for your work in helping and supporting Discourse communities by joining our Community Support Program.","topic_url":"/t/about-the-community-support-program-category/193906","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":124,"name":"General","color":"25AAE2","text_color":"FFFFFF","slug":"general","topic_count":155,"post_count":1408,"position":35,"description":"Create topics here that don’t fit into any other existing category.","description_text":"Create topics here that don’t fit into any other existing category.","description_excerpt":"Create topics here that don’t fit into any other existing category.","topic_url":"/t/about-the-general-category/237517","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":17,"name":"Uncategorized","color":"AB9364","text_color":"FFFFFF","slug":"uncategorized","topic_count":0,"post_count":0,"position":77,"description":"Topics that don't need a category, or don't fit into any other existing category.","description_text":"Topics that don't need a category, or don't fit into any other existing category.","description_excerpt":"Topics that don't need a category, or don't fit into any other existing category.","topic_url":"/t/","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false}]}}} \ No newline at end of file diff --git a/spec/fixtures/search_meta/site.json b/spec/fixtures/search_meta/site.json deleted file mode 100644 index 9651e0ce..00000000 --- a/spec/fixtures/search_meta/site.json +++ /dev/null @@ -1 +0,0 @@ -{"default_archetype":"regular","notification_types":{"mentioned":1,"replied":2,"quoted":3,"edited":4,"liked":5,"private_message":6,"invited_to_private_message":7,"invitee_accepted":8,"posted":9,"moved_post":10,"linked":11,"granted_badge":12,"invited_to_topic":13,"custom":14,"group_mentioned":15,"group_message_summary":16,"watching_first_post":17,"topic_reminder":18,"liked_consolidated":19,"post_approved":20,"code_review_commit_approved":21,"membership_request_accepted":22,"membership_request_consolidated":23,"bookmark_reminder":24,"reaction":25,"votes_released":26,"event_reminder":27,"event_invitation":28,"chat_mention":29,"chat_message":30,"chat_invitation":31,"chat_group_mention":32,"chat_quoted":33,"assigned":34,"question_answer_user_commented":35,"watching_category_or_tag":36,"new_features":37,"admin_problems":38,"following":800,"following_created_topic":801,"following_replied":802,"circles_activity":900},"post_types":{"regular":1,"moderator_action":2,"small_action":3,"whisper":4},"user_tips":{"first_notification":1,"topic_timeline":2,"post_menu":3,"topic_notification_levels":4,"suggested_topics":5,"admin_guide":6},"trust_levels":{"newuser":0,"basic":1,"member":2,"regular":3,"leader":4},"groups":[{"id":183,"name":"ai-personas","flair_url":null,"flair_bg_color":"","flair_color":""},{"id":160,"name":"community-moderators","flair_url":null,"flair_bg_color":"","flair_color":""},{"id":184,"name":"glimmer-search-menu","flair_url":null,"flair_bg_color":"","flair_color":""},{"id":157,"name":"new-new-testers","flair_url":null,"flair_bg_color":"","flair_color":""},{"id":48,"name":"plugin_authors","flair_url":"fa-plug","flair_bg_color":"dddddd","flair_color":"111111"},{"id":148,"name":"support-advocates","flair_url":"https://d11a6trkgmumsb.cloudfront.net/original/3X/e/4/e4038d4d9848de2eabab38e17b8bdb69da154024.svg","flair_bg_color":"FFFFFF","flair_color":""},{"id":151,"name":"support-enthusiasts","flair_url":"https://d11a6trkgmumsb.cloudfront.net/original/3X/1/3/13f5d8d7e56be8a6a1ea3de009b985a548aec8d4.svg","flair_bg_color":"FFFFFF","flair_color":""},{"id":142,"name":"support-experts","flair_url":"https://d11a6trkgmumsb.cloudfront.net/original/3X/e/2/e250ec403580530d19e6a9ed42d0d525a51a9dbe.svg","flair_bg_color":"FFFFFF","flair_color":""},{"id":118,"name":"support-explorers","flair_url":"https://d11a6trkgmumsb.cloudfront.net/original/4X/d/1/b/d1ba0acf09b9d01f87f9e05bbee1dc5b0e316d5f.png","flair_bg_color":"dddddd","flair_color":"111111"},{"id":47,"name":"team","flair_url":"https://d11a6trkgmumsb.cloudfront.net/original/3X/e/b/ebee30bd98aef20357e4a177a5a1e45b877ce088.svg","flair_bg_color":"","flair_color":"111"},{"id":73,"name":"theme_authors","flair_url":"fa-paint-brush","flair_bg_color":"ddd","flair_color":"111"},{"id":84,"name":"theme_creator","flair_url":"fa-palette","flair_bg_color":"ddd","flair_color":"111"},{"id":50,"name":"translators","flair_url":"fa-globe","flair_bg_color":"ddd","flair_color":"111"}],"filters":["latest","unread","new","unseen","top","read","posted","bookmarks","hot"],"periods":["all","yearly","quarterly","monthly","weekly","daily"],"top_menu_items":["latest","unread","new","unseen","top","read","posted","bookmarks","hot","categories"],"anonymous_top_menu_items":["latest","top","categories","hot","categories","top"],"uncategorized_category_id":17,"user_field_max_length":2048,"post_action_types":[{"id":2,"name_key":"like","name":"Like","description":"Like this post","short_description":"Like this post","is_flag":false,"is_custom_flag":false},{"id":3,"name_key":"off_topic","name":"Off-Topic","description":"This post is not relevant to the current discussion as defined by the title and first post, and should probably be moved elsewhere.","short_description":"Not relevant to the discussion","is_flag":true,"is_custom_flag":false},{"id":4,"name_key":"inappropriate","name":"Inappropriate","description":"This post contains content that a reasonable person would consider offensive, abusive, to be hateful conduct or a violation of \u003ca href=\"/guidelines\"\u003eour community guidelines\u003c/a\u003e.","short_description":"A violation of \u003ca href=\"/guidelines\"\u003eour community guidelines\u003c/a\u003e","is_flag":true,"is_custom_flag":false},{"id":8,"name_key":"spam","name":"Spam","description":"This post is an advertisement, or vandalism. It is not useful or relevant to the current topic.","short_description":"This is an advertisement or vandalism","is_flag":true,"is_custom_flag":false},{"id":6,"name_key":"notify_user","name":"Send @%{username} a message","description":"I want to talk to this person directly and personally about their post.","short_description":"I want to talk to this person directly and personally about their post.","is_flag":true,"is_custom_flag":true},{"id":10,"name_key":"illegal","name":"Illegal","description":"This post requires staff attention because I believe it contains content that is illegal.","short_description":"This is illegal","is_flag":true,"is_custom_flag":true},{"id":null,"name_key":null,"name":"Translation missing: en.post_action_types..title","description":"Translation missing: en.post_action_types.description","short_description":"Translation missing: en.post_action_types.short_description","is_flag":false,"is_custom_flag":false},{"id":7,"name_key":"notify_moderators","name":"Something Else","description":"This post requires staff attention for another reason not listed above.","short_description":"Requires staff attention for another reason","is_flag":true,"is_custom_flag":true}],"topic_flag_types":[{"id":4,"name_key":"inappropriate","name":"Inappropriate","description":"This topic contains content that a reasonable person would consider offensive, abusive, to be hateful conduct or a violation of \u003ca href=\"/guidelines\"\u003eour community guidelines\u003c/a\u003e.","short_description":"A violation of \u003ca href=\"/guidelines\"\u003eour community guidelines\u003c/a\u003e","is_flag":true,"is_custom_flag":false},{"id":8,"name_key":"spam","name":"Spam","description":"This topic is an advertisement. It is not useful or relevant to this site, but promotional in nature.","short_description":"This is an advertisement","is_flag":true,"is_custom_flag":false},{"id":10,"name_key":"illegal","name":"Illegal","description":"This topic requires staff attention because I believe it contains content that is illegal.","short_description":"This is illegal","is_flag":true,"is_custom_flag":true},{"id":null,"name_key":null,"name":"Translation missing: en.topic_flag_types..title","description":"Translation missing: en.topic_flag_types.description","short_description":"Translation missing: en.topic_flag_types.short_description","is_flag":false,"is_custom_flag":false},{"id":7,"name_key":"notify_moderators","name":"Something Else","description":"This topic requires general staff attention based on the \u003ca href=\"/guidelines\"\u003eguidelines\u003c/a\u003e, \u003ca href=\"/tos\"\u003eTOS\u003c/a\u003e, or for another reason not listed above.","short_description":"Requires staff attention for another reason","is_flag":true,"is_custom_flag":true}],"can_create_tag":false,"can_tag_topics":false,"can_tag_pms":false,"tags_filter_regexp":"[/\\?#\\[\\]@!\\$\u0026'\\(\\)\\*\\+,;=\\.%\\\\`^\\s|\\{\\}\"\u003c\u003e]+","top_tags":["how-to","chat","rest-api","sql-query","unsupported-install","email","pr-welcome","completed","ai","activity-summary","official","solved","sidebar","release-notes","server-resources","calendar-and-event","badges","topic-voting","docker","tags","advertising","invites","search","sql-triggered-badge","subscriptions","new-feature","chat-integration","wordpress","groups","themes","css"],"navigation_menu_site_top_tags":[{"name":"how-to","description":"How to guides contain steps to follow to solve a specific problem","pm_only":false},{"name":"chat","description":null,"pm_only":false},{"name":"rest-api","description":"Topics about making an external request to Discourse","pm_only":false},{"name":"sql-query","description":"SQL queries for the data-explorer","pm_only":false},{"name":"unsupported-install","description":null,"pm_only":false}],"topic_featured_link_allowed_category_ids":[149,120,122,157,121,153,127,9,136,138,61,83,96,56,132,123,144,104,10,27,137,25,124,22,134,24,110,105,143,119,145,135,129,7,30,89,131,150,6,155,31,20,133,55,102,148,147,95,152,151,128,154,21,139,108,69,3,14,125,63,2,126,115,106,1,118,117,17,53,13,5,65,8,67,35],"user_themes":[{"theme_id":272,"name":"Accessible contrast (dark)","default":false,"color_scheme_id":85},{"theme_id":271,"name":"Accessible contrast (light)","default":false,"color_scheme_id":84},{"theme_id":242,"name":"Air Theme","default":false,"color_scheme_id":82},{"theme_id":337,"name":"Central","default":false,"color_scheme_id":100},{"theme_id":335,"name":"CentralStaffOnly","default":false,"color_scheme_id":96},{"theme_id":31,"name":"Dark","default":false,"color_scheme_id":86},{"theme_id":140,"name":"Default","default":false,"color_scheme_id":34},{"theme_id":281,"name":"Default (full-width)","default":false,"color_scheme_id":34},{"theme_id":51,"name":"Discourse-classic","default":false,"color_scheme_id":34},{"theme_id":296,"name":"Fully","default":false,"color_scheme_id":null},{"theme_id":131,"name":"Ghost","default":false,"color_scheme_id":66},{"theme_id":80,"name":"Graceful","default":false,"color_scheme_id":54},{"theme_id":83,"name":"Grey Amber","default":false,"color_scheme_id":57},{"theme_id":232,"name":"Hidden Whispers","default":false,"color_scheme_id":null},{"theme_id":34,"name":"Material Design","default":false,"color_scheme_id":37},{"theme_id":331,"name":"Meta Branded","default":true,"color_scheme_id":34},{"theme_id":125,"name":"Minima Dark","default":false,"color_scheme_id":43},{"theme_id":299,"name":"redditish","default":false,"color_scheme_id":null},{"theme_id":30,"name":"Sam's Simple Theme","default":false,"color_scheme_id":null}],"user_color_schemes":[{"id":93,"name":"Central Dark","is_dark":true},{"id":95,"name":"Central Dark","is_dark":true},{"id":100,"name":"Central Light","is_dark":false},{"id":96,"name":"Central Light","is_dark":false},{"id":86,"name":"Dark","is_dark":true},{"id":34,"name":"Default Light","is_dark":false},{"id":87,"name":"Solarized Light","is_dark":false},{"id":85,"name":"WCAG Dark","is_dark":true},{"id":84,"name":"WCAG Light","is_dark":false}],"default_dark_color_scheme":null,"censored_regexp":[{"(?:\\P{L}|^)(EICARTESTCENSOR)(?=\\P{L}|$)":{"case_sensitive":false}}],"custom_emoji_translation":{},"watched_words_replace":{"(?:\\P{L}|^)(¯_\\(ツ\\)_/¯)(?=\\P{L}|$)":{"word":"¯_(ツ)_/¯","replacement":"¯\\_(ツ)_/¯","case_sensitive":false}},"watched_words_link":{"(?:\\P{L}|^)(Data Explorer)(?=\\P{L}|$)":{"word":"Data Explorer","replacement":"https://meta.discourse.org/t/32566?silent=true","case_sensitive":false},"(?:\\P{L}|^)(discourseconnect)(?=\\P{L}|$)":{"word":"discourseconnect","replacement":"https://meta.discourse.org/t/13045?silent=true","case_sensitive":false},"(?:\\P{L}|^)(discourse connect)(?=\\P{L}|$)":{"word":"discourse connect","replacement":"https://meta.discourse.org/t/13045?silent=true","case_sensitive":false},"(?:\\P{L}|^)(discourse sso)(?=\\P{L}|$)":{"word":"discourse sso","replacement":"https://meta.discourse.org/t/13045?silent=true","case_sensitive":false},"(?:\\P{L}|^)(official install)(?=\\P{L}|$)":{"word":"official install","replacement":"https://meta.discourse.org/t/142537?silent=true","case_sensitive":false},"(?:\\P{L}|^)(standard install)(?=\\P{L}|$)":{"word":"standard install","replacement":"https://meta.discourse.org/t/142537?silent=true","case_sensitive":false},"(?:\\P{L}|^)(safe mode)(?=\\P{L}|$)":{"word":"safe mode","replacement":"https://meta.discourse.org/t/53504?silent=true","case_sensitive":false}},"categories":[{"id":67,"name":"announcements","color":"ED207B","text_color":"FFFFFF","slug":"announcements","topic_count":312,"post_count":3973,"position":0,"description":"The place for all Discourse announcements.","description_text":"The place for all Discourse announcements.","description_excerpt":"The place for all Discourse announcements.","topic_url":"/t/about-the-announcements-category/68629","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":true,"sort_order":null,"sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_ready":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_enabled":true,"activity_pub_username":"announcements","activity_pub_name":"Announcements","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":["new-feature","security"],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":true,"activity_pub_ready":true,"activity_pub_actor":{"id":2,"handle":"announcements@meta.discourse.org","name":"Announcements"},"activity_pub_username":"announcements","activity_pub_name":"Announcements","activity_pub_default_visibility":"public","activity_pub_publication_type":"first_post","activity_pub_post_object_type":"Note","uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":13,"name":"blog","color":"8080ff","text_color":"FFFFFF","slug":"blog","topic_count":170,"post_count":1477,"position":1,"description":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","description_text":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","description_excerpt":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","topic_url":"/t/about-the-blog-category/5250","read_restricted":false,"permission":null,"parent_category_id":67,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"created","sort_ascending":false,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":10,"name":"documentation","color":"00A94F","text_color":"FFFFFF","slug":"documentation","topic_count":2,"post_count":2,"position":2,"description":"Documentation for how to use Discourse, install and configure sites, and troubleshoot common issues. Includes topics for tutorials, how-tos, general reference and troubleshooting. Topics need to be created in one of the subcategories, and may only be created by trust level 2 and up.","description_text":"Documentation for how to use Discourse, install and configure sites, and troubleshoot common issues. Includes topics for tutorials, how-tos, general reference and troubleshooting. Topics need to be created in one of the subcategories, and may only be created by trust level 2 and up.","description_excerpt":"Documentation for how to use Discourse, install and configure sites, and troubleshoot common issues. Includes topics for tutorials, how-tos, general reference and troubleshooting. Topics need to be created in one of the subcategories, and may only be created by trust level 2 and up.","topic_url":"/t/about-the-documentation-category/2629","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"top","subcategory_list_style":"boxes","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_ready":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Article","activity_pub_publication_type":"full_topic","activity_pub_username":"documentation","activity_pub_enabled":true,"activity_pub_name":"Documentation"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":true,"activity_pub_ready":true,"activity_pub_actor":{"id":31769,"handle":"documentation@meta.discourse.org","name":"Documentation"},"activity_pub_username":"documentation","activity_pub_name":"Documentation","activity_pub_default_visibility":"public","activity_pub_publication_type":"full_topic","activity_pub_post_object_type":"Article","uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":53,"name":"admins","color":"F15D22","text_color":"FFFFFF","slug":"admins","topic_count":222,"post_count":1703,"position":3,"description":"Guides for Discourse admins and community managers with admin access.","description_text":"Guides for Discourse admins and community managers with admin access.","description_excerpt":"Guides for Discourse admins and community managers with admin access.","topic_url":"/t/about-the-admins-category/56207","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":["hosted-support","migrations"],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":125,"name":"moderators","color":"40d0e2","text_color":"FFFFFF","slug":"moderators","topic_count":16,"post_count":71,"position":4,"description":"Documentation for moderators and community managers using Discourse.","description_text":"Documentation for moderators and community managers using Discourse.","description_excerpt":"Documentation for moderators and community managers using Discourse.","topic_url":"/t/about-the-moderators-category/238588","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":126,"name":"users","color":"0088CC","text_color":"FFFFFF","slug":"users","topic_count":53,"post_count":184,"position":5,"description":"Documentation for all members of communities running Discourse.","description_text":"Documentation for all members of communities running Discourse.","description_excerpt":"Documentation for all members of communities running Discourse.","topic_url":"/t/about-the-users-category/238917","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":55,"name":"sysadmin","color":"E9DD00","text_color":"FFFFFF","slug":"sysadmin","topic_count":175,"post_count":2803,"position":6,"description":"Documentation for self-hosters and Discourse system administrators.","description_text":"Documentation for self-hosters and Discourse system administrators.","description_excerpt":"Documentation for self-hosters and Discourse system administrators.","topic_url":"/t/about-the-sysadmin-category/56209","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":["migrations"],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":127,"name":"theme developers","color":"92278F","text_color":"FFFFFF","slug":"theme-developers","topic_count":41,"post_count":215,"position":7,"description":"Documentation for developing themes and components that can be installed by admins.","description_text":"Documentation for developing themes and components that can be installed by admins.","description_excerpt":"Documentation for developing themes and components that can be installed by admins.","topic_url":"/t/about-the-theme-developers-category/239285","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":56,"name":"developers","color":"00A94F","text_color":"FFFFFF","slug":"devs","topic_count":96,"post_count":1185,"position":8,"description":"Documentation for developing features, plugins, or integrations with Discourse.","description_text":"Documentation for developing features, plugins, or integrations with Discourse.","description_excerpt":"Documentation for developing features, plugins, or integrations with Discourse.","topic_url":"/t/about-the-developers-category/56210","read_restricted":false,"permission":null,"parent_category_id":10,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_ready":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Article","activity_pub_enabled":true,"activity_pub_username":"developer-docs","activity_pub_name":"Developer Documentation","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":true,"activity_pub_ready":true,"activity_pub_actor":{"id":35545,"handle":"developer-docs@meta.discourse.org","name":"Developer Documentation"},"activity_pub_username":"developer-docs","activity_pub_name":"Developer Documentation","activity_pub_default_visibility":"public","activity_pub_publication_type":"first_post","activity_pub_post_object_type":"Article","uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":6,"name":"support","color":"CEA9A9","text_color":"FFFFFF","slug":"support","topic_count":15841,"post_count":103687,"position":9,"description":"The category for general support questions on using your Discourse site.","description_text":"The category for general support questions on using your Discourse site.","description_excerpt":"The category for general support questions on using your Discourse site.","topic_url":"/t/about-the-support-category/389","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"\u003e Before asking, did you search first? Press 🔍 at the upper right to search.","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_accepted_answers":"true","enable_unassigned_filter":"false","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":21,"name":"wordpress","color":"F9CFCF","text_color":"FFFFFF","slug":"wordpress","topic_count":732,"post_count":5110,"position":10,"description":"Support for the official Discourse WordPress plugin at \u003ca href=\"https://github.com/discourse/wp-discourse\" class=\"inline-onebox\"\u003eGitHub - discourse/wp-discourse: WordPress plugin that lets you use Discourse as the community engine for a WordPress blog\u003c/a\u003e","description_text":"Support for the official Discourse WordPress plugin at GitHub - discourse/wp-discourse: WordPress plugin that lets you use Discourse as the community engine for a WordPress blog","description_excerpt":"Support for the official Discourse WordPress plugin at \u003ca href=\"https://github.com/discourse/wp-discourse\" class=\"inline-onebox\"\u003eGitHub - discourse/wp-discourse: WordPress plugin that lets you use Discourse as the community engine for a WordPress blog\u003c/a\u003e","topic_url":"/t/about-the-wordpress-category/12282","read_restricted":false,"permission":null,"parent_category_id":6,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":1,"name":"bug","color":"e9dd00","text_color":"000000","slug":"bug","topic_count":5153,"post_count":35973,"position":11,"description":"A bug report means \u003cstrong\u003esomething is broken, preventing normal/typical use of Discourse\u003c/strong\u003e. Do be sure to search prior to submitting bugs. \u003ca href=\"https://meta.discourse.org/t/how-to-write-a-good-bug-report/183671/\"\u003eInclude repro steps\u003c/a\u003e, and only describe one bug per topic please.","description_text":"A bug report means something is broken, preventing normal/typical use of Discourse. Do be sure to search prior to submitting bugs. Include repro steps, and only describe one bug per topic please.","description_excerpt":"A bug report means something is broken, preventing normal/typical use of Discourse. Do be sure to search prior to submitting bugs. \u003ca href=\"https://meta.discourse.org/t/how-to-write-a-good-bug-report/183671/\"\u003eInclude repro steps\u003c/a\u003e, and only describe one bug per topic please.","topic_url":"/t/about-the-bug-category/2","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_accepted_answers":null,"enable_unassigned_filter":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":9,"name":"ux","color":"5F497A","text_color":"FFFFFF","slug":"ux","topic_count":2700,"post_count":18225,"position":12,"description":"Discussion about the user interface of Discourse and how features are presented (including language and UI elements).","description_text":"Discussion about the user interface of Discourse and how features are presented (including language and UI elements).","description_excerpt":"Discussion about the user interface of Discourse and how features are presented (including language and UI elements).","topic_url":"/t/about-the-ux-category/2628","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":2,"name":"feature","color":"0E76BD","text_color":"FFFFFF","slug":"feature","topic_count":6997,"post_count":58067,"position":13,"description":"Discussion about existing Discourse features, how they can be improved or enhanced, and how proposed new features could work.","description_text":"Discussion about existing Discourse features, how they can be improved or enhanced, and how proposed new features could work.","description_excerpt":"Discussion about existing Discourse features, how they can be improved or enhanced, and how proposed new features could work.","topic_url":"/t/about-the-feature-category/11","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_ready":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_username":"feature","activity_pub_name":"Feature Requests","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post","activity_pub_enabled":true},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":true,"activity_pub_ready":true,"activity_pub_actor":{"id":1,"handle":"feature@meta.discourse.org","name":"Feature Requests"},"activity_pub_username":"feature","activity_pub_name":"Feature Requests","activity_pub_default_visibility":"public","activity_pub_publication_type":"first_post","activity_pub_post_object_type":"Note","uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":148,"name":"data \u0026 reporting","color":"D24899","text_color":"FFFFFF","slug":"data-reporting","topic_count":631,"post_count":3056,"position":14,"description":"This is the category for everything related to Discourse data and reporting. Here, you can discuss dashboard reports, custom badge queries, data-explorer queries and any other analytic add-ons.","description_text":"This is the category for everything related to Discourse data and reporting. Here, you can discuss dashboard reports, custom badge queries, data-explorer queries and any other analytic add-ons.","description_excerpt":"This is the category for everything related to Discourse data and reporting. Here, you can discuss dashboard reports, custom badge queries, data-explorer queries and any other analytic add-ons.","topic_url":"/t/about-the-data-reporting-category/274664","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","enable_accepted_answers":"true","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":24,"name":"sso","color":"d47711","text_color":"FFFFFF","slug":"sso","topic_count":493,"post_count":2546,"position":15,"description":"For queries specifically about SSO (single sign-on) and login using third-party providers (Google, Facebook, GitHub etc). See the \u003ca href=\"https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045\"\u003eofficial documentation on DiscourseConnect SSO\u003c/a\u003e.","description_text":"For queries specifically about SSO (single sign-on) and login using third-party providers (Google, Facebook, GitHub etc). See the official documentation on DiscourseConnect SSO.","description_excerpt":"For queries specifically about SSO (single sign-on) and login using third-party providers (Google, Facebook, GitHub etc). See the \u003ca href=\"https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045\"\u003eofficial documentation on DiscourseConnect SSO\u003c/a\u003e.","topic_url":"/t/about-the-sso-category/13110","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":65,"name":"community","color":"12A89D","text_color":"FFFFFF","slug":"community","topic_count":866,"post_count":8844,"position":16,"description":"A great platform doesn’t guarantee success. Community building is a science. This category is for discussions about launching, building, growing and managing a thriving community.","description_text":"A great platform doesn’t guarantee success. Community building is a science. This category is for discussions about launching, building, growing and managing a thriving community.","description_excerpt":"A great platform doesn’t guarantee success. Community building is a science. This category is for discussions about launching, building, growing and managing a thriving community.","topic_url":"/t/about-the-community-category/67750","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":7,"name":"dev","color":"292929","text_color":"fff","slug":"dev","topic_count":3467,"post_count":20210,"position":17,"description":"The category for all things Discourse Development. Building a customization for yourself or the community? Then this is the category for you!","description_text":"The category for all things Discourse Development. Building a customization for yourself or the community? Then this is the category for you!","description_excerpt":"The category for all things Discourse Development. Building a customization for yourself or the community? Then this is the category for you!","topic_url":"/t/about-the-dev-category/1026","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"boxes","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":27,"name":"translations","color":"808281","text_color":"FFFFFF","slug":"translations","topic_count":297,"post_count":1832,"position":18,"description":"This category is for discussion about localizing Discourse.","description_text":"This category is for discussion about localizing Discourse.","description_excerpt":"This category is for discussion about localizing Discourse.","topic_url":"/t/about-the-translations-category/14549","read_restricted":false,"permission":null,"parent_category_id":7,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":14,"name":"marketplace","color":"8C6238","text_color":"FFFFFF","slug":"marketplace","topic_count":1162,"post_count":5865,"position":19,"description":"This is your hub for all Discourse-related commerce: jobs, gigs, plugins, themes, hosting, and more.","description_text":"This is your hub for all Discourse-related commerce: jobs, gigs, plugins, themes, hosting, and more.","description_excerpt":"This is your hub for all Discourse-related commerce: jobs, gigs, plugins, themes, hosting, and more.","topic_url":"/t/about-the-marketplace-category/5425","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"What would you like done?\n\nWhen do you need it done?\n\nWhat is your budget, in $ USD that you can offer for this task?\n\n\u003c!-- We encourage caution and due diligence when engaging with potential contractors or clients. Verify their credentials, check previous work, and ensure a transparent and legitimate transaction. Always remember, your safety and security in the marketplace is your responsibility. --\u003e","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":["delivered"],"allowed_tag_groups":[],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":22,"name":"plugin","color":"F7941D","text_color":"FFFFFF","slug":"plugin","topic_count":315,"post_count":10429,"position":20,"description":"A directory of Discourse plugins, both official and third-party.","description_text":"A directory of Discourse plugins, both official and third-party.","description_excerpt":"A directory of Discourse plugins, both official and third-party.","topic_url":"/t/about-the-plugin-category/12648","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"| | | |\n| - | - | - |\n| :information_source: | **Summary** | In a few words, what does this plugin do? |\n| :hammer_and_wrench: | **Repository Link** | \u003c\u003e |\n| :open_book: | **Install Guide** | [How to install plugins in Discourse](https://meta.discourse.org/t/install-plugins-in-discourse/19157) |\n\n\u003cbr\u003e \n\n### Features\n \nDescribe the major features of the plugin\n \n### Configuration\n \nInclude detailed steps on how to configure the plugin (include screenshots where necessary)\n \n### CHANGELOG\n- Add new bullets when major features are committed here\n \n### TODO\n- Add any #pr-welcome TODO tasks here","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"boxes","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":["Resource Status"],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":5,"name":"extras","color":"25AAE2","text_color":"FFFFFF","slug":"extras","topic_count":90,"post_count":985,"position":21,"description":"A directory of all extensions \u0026amp; integrations for Discourse which are \u003cem\u003enot\u003c/em\u003e Discourse plugins, i.e. a CMS plugin, a browser extension or a native application.","description_text":"A directory of all extensions \u0026amp; integrations for Discourse which are not Discourse plugins, i.e. a CMS plugin, a browser extension or a native application.","description_excerpt":"A directory of all extensions \u0026amp; integrations for Discourse which are not Discourse plugins, i.e. a CMS plugin, a browser extension or a native application.","topic_url":"/t/about-the-extras-category/28","read_restricted":false,"permission":null,"parent_category_id":22,"notification_level":1,"topic_template":"","has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":["Resource Status"],"allow_global_tags":true,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":61,"name":"theme","color":"E43D30","text_color":"FFFFFF","slug":"theme","topic_count":66,"post_count":2138,"position":22,"description":"Themes are expansive customizations that change multiple elements of the style of your forum design, and often also include additional front-end features.","description_text":"Themes are expansive customizations that change multiple elements of the style of your forum design, and often also include additional front-end features.","description_excerpt":"Themes are expansive customizations that change multiple elements of the style of your forum design, and often also include additional front-end features.","topic_url":"/t/about-the-theme-category/60925","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"||||\n|-|-|-|\n| :information_source: | **Summary** | ADD SHORT SUMMARY \n| :eyeglasses:|**Preview**| PREVIEW_LINK |\n| :hammer_and_wrench:|**Repository**| REPOSITORY_LINK |\n| :question:|**Install Guide**|[How to install a theme or theme component](https://meta.discourse.org/t/how-do-i-install-a-theme-or-theme-component/63682)|\n| :open_book:|**New to Discourse Themes?**| [Beginner’s guide to using Discourse Themes](https://meta.discourse.org/t/beginners-guide-to-using-discourse-themes/91966)\n\n\u003c!-- Describe this theme in one or two sentences --\u003e\n\nShort description...\n\n\u003c!-- Add screenshots (if applicable) --\u003e\n\nScreenshots...\n\n\u003c!-- Add more details and explain the settings (if applicable) --\u003e\n\nDetailed description...\n","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"none","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":["color-palette"],"allowed_tag_groups":["Resource Status"],"allow_global_tags":true,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":120,"name":"theme-component","color":"1dedf8","text_color":"FFFFFF","slug":"theme-component","topic_count":300,"post_count":7516,"position":23,"description":"Theme components are customizations that change surface elements of your forum design, or add extra front-end features.","description_text":"Theme components are customizations that change surface elements of your forum design, or add extra front-end features.","description_excerpt":"Theme components are customizations that change surface elements of your forum design, or add extra front-end features.","topic_url":"/t/about-the-theme-component-category/232731","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"| | | |\n| - | - | - |\n| :information_source: | **Summary** | ADD SHORT SUMMARY |\n| :eyeglasses: |**Preview**| PREVIEW_LINK |\n| :hammer_and_wrench: | **Repository**| REPOSITORY_LINK |\n| :question: | **Install Guide** | [How to install a theme or theme component](https://meta.discourse.org/t/how-do-i-install-a-theme-or-theme-component/63682) |\n| :open_book: | **New to Discourse Themes?** | [Beginner’s guide to using Discourse Themes](https://meta.discourse.org/t/beginners-guide-to-using-discourse-themes/91966) |\n\n\u003c!-- Fill in \"repoName\" and \"repoURL\" for the automatic install button --\u003e\n\n[wrap=theme-install-button repoName=\"Component's name\" repoUrl=\"GitHub repository link\"]\nInstall this theme component\n[/wrap]\n\n\u003c!-- Describe this theme/component in one or two sentences --\u003e\n\nShort description...\n\n\u003c!-- Add screenshots (if applicable) --\u003e\n\nScreenshots...\n\n\u003c!-- Add more details and explain the settings (if applicable) --\u003e\n\nDetailed description...","has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"none","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":["Resource Status"],"allow_global_tags":true,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":31,"name":"installation","color":"997E7E","text_color":"FFFFFF","slug":"installation","topic_count":3426,"post_count":29611,"position":24,"description":"Getting Discourse up and running, keeping it going, upgrading, and any other general sysadmin maintenance.","description_text":"Getting Discourse up and running, keeping it going, upgrading, and any other general sysadmin maintenance.","description_excerpt":"Getting Discourse up and running, keeping it going, upgrading, and any other general sysadmin maintenance.","topic_url":"/t/about-the-installation-category/21019","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":106,"name":"migration","color":"652D90","text_color":"FFFFFF","slug":"migration","topic_count":215,"post_count":1558,"position":25,"description":"You want to migrate your community to Discourse? Awesome! This is the category where you can ask questions, get help, and document your Discourse migration journey.","description_text":"You want to migrate your community to Discourse? Awesome! This is the category where you can ask questions, get help, and document your Discourse migration journey.","description_excerpt":"You want to migrate your community to Discourse? Awesome! This is the category where you can ask questions, get help, and document your Discourse migration journey.","topic_url":"/t/about-the-migration-category/196969","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":"true","activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":["Migration"],"allow_global_tags":true,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":8,"name":"hosting","color":"00AEEF","text_color":"FFFFFF","slug":"hosting","topic_count":501,"post_count":4117,"position":26,"description":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","description_text":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","description_excerpt":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","topic_url":"/t/about-the-hosting-category/2626","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post","enable_accepted_answers":"true"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":30,"name":"releases","color":"BF1E2E","text_color":"FFFFFF","slug":"releases","topic_count":23,"post_count":104,"position":27,"description":"Outlining each official release of Discourse, and plans for future releases.","description_text":"Outlining each official release of Discourse, and plans for future releases.","description_excerpt":"Outlining each official release of Discourse, and plans for future releases.","topic_url":"/t/about-the-releases-category/20857","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"created","sort_ascending":false,"show_subcategory_list":true,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":3,"name":"site feedback","color":"888","text_color":"FFFFFF","slug":"site-feedback","topic_count":417,"post_count":3220,"position":28,"description":"Discussion about \u003ca href=\"http://meta.discourse.org\"\u003emeta.discourse.org\u003c/a\u003e itself - the organization of this forum, how it works, and how we can improve this site.","description_text":"Discussion about meta.discourse.org itself - the organization of this forum, how it works, and how we can improve this site.","description_excerpt":"Discussion about \u003ca href=\"http://meta.discourse.org\"\u003emeta.discourse.org\u003c/a\u003e itself - the organization of this forum, how it works, and how we can improve this site.","topic_url":"/t/about-the-site-feedback-category/24","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":true,"sort_order":"","sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":152,"name":"theme feedback","color":"ED207B","text_color":"FFFFFF","slug":"theme-feedback","topic_count":11,"post_count":44,"position":29,"description":"This is the category to gather all the UX reports for \u003ca href=\"https://meta.discourse.org/t/we-have-a-new-default-theme-here-on-meta/284692\"\u003eour new theme on meta\u003c/a\u003e. It also uses the new Form Templates feature that I’ve been wanting to try out.","description_text":"This is the category to gather all the UX reports for our new theme on meta. It also uses the new Form Templates feature that I’ve been wanting to try out.","description_excerpt":"This is the category to gather all the UX reports for \u003ca href=\"https://meta.discourse.org/t/we-have-a-new-default-theme-here-on-meta/284692\"\u003eour new theme on meta\u003c/a\u003e. It also uses the new Form Templates feature that I’ve been wanting to try out.","topic_url":"/t/about-the-theme-feedback-category/284904","read_restricted":false,"permission":null,"parent_category_id":3,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[1],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":157,"name":"forum summaries","color":"72e9a7","text_color":"FFFFFF","slug":"forum-summaries","topic_count":4,"post_count":36,"position":30,"description":"Stay up-to-date with the pulse of our community through AI-crafted summaries. This category harnesses the power of artificial intelligence to collate and condense forum activities, providing you with comprehensive yet succinct overviews. From emerging discussions to trending topics, our AI summaries deliver the essence of the Discourse community’s heartbeat, right at your fingertips.","description_text":"Stay up-to-date with the pulse of our community through AI-crafted summaries. This category harnesses the power of artificial intelligence to collate and condense forum activities, providing you with comprehensive yet succinct overviews. From emerging discussions to trending topics, our AI summaries deliver the essence of the Discourse community’s heartbeat, right at your fingertips.","description_excerpt":"Stay up-to-date with the pulse of our community through AI-crafted summaries. This category harnesses the power of artificial intelligence to collate and condense forum activities, providing you with comprehensive yet succinct overviews. From emerging discussions to trending topics, our AI summarie\u0026hellip;","topic_url":"/t/about-the-forum-summaries-category/291765","read_restricted":false,"permission":null,"parent_category_id":3,"notification_level":0,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":35,"name":"praise","color":"9EB83B","text_color":"FFFFFF","slug":"praise","topic_count":294,"post_count":1091,"position":31,"description":"Have something nice to say about Discourse?","description_text":"Have something nice to say about Discourse?","description_excerpt":"Have something nice to say about Discourse?","topic_url":"/t/about-the-praise-category/30010","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":true,"sort_order":null,"sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":63,"name":"comparison","color":"F1592A","text_color":"FFFFFF","slug":"comparison","topic_count":11,"post_count":137,"position":32,"description":"Topics comparing Discourse to other platforms.","description_text":"Topics comparing Discourse to other platforms.","description_excerpt":"Topics comparing Discourse to other platforms.","topic_url":"/t/about-the-comparison-category/65736","read_restricted":false,"permission":null,"parent_category_id":35,"notification_level":1,"topic_template":"### About PLATFORM_NAME\n\nA blurb about the platform being compared to Discourse. \n\nhttp://link.to.website\n\n ### Previous discussions:\n\nlinks to previous discussions related\n\n### Importer status\n\nIs there an official Discourse importer? Where is it? ","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"latest","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":105,"name":"community support program","color":"92278F","text_color":"FFFFFF","slug":"support-program","topic_count":4,"post_count":27,"position":33,"description":"Get recognition for your work in helping and supporting Discourse communities by joining our Community Support Program.","description_text":"Get recognition for your work in helping and supporting Discourse communities by joining our Community Support Program.","description_excerpt":"Get recognition for your work in helping and supporting Discourse communities by joining our Community Support Program.","topic_url":"/t/about-the-community-support-program-category/193906","read_restricted":false,"permission":null,"notification_level":1,"topic_template":"","has_children":false,"sort_order":"","sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":"","subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":true,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":"","form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":124,"name":"General","color":"25AAE2","text_color":"FFFFFF","slug":"general","topic_count":155,"post_count":1408,"position":35,"description":"Create topics here that don’t fit into any other existing category.","description_text":"Create topics here that don’t fit into any other existing category.","description_excerpt":"Create topics here that don’t fit into any other existing category.","topic_url":"/t/about-the-general-category/237517","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":false,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows_with_featured_topics","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"enable_unassigned_filter":null,"enable_accepted_answers":null,"activity_pub_default_visibility":"public","activity_pub_post_object_type":"Note","activity_pub_publication_type":"first_post"},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false},{"id":17,"name":"Uncategorized","color":"AB9364","text_color":"FFFFFF","slug":"uncategorized","topic_count":0,"post_count":0,"position":77,"description":"Topics that don't need a category, or don't fit into any other existing category.","description_text":"Topics that don't need a category, or don't fit into any other existing category.","description_excerpt":"Topics that don't need a category, or don't fit into any other existing category.","topic_url":"/t/","read_restricted":false,"permission":null,"notification_level":1,"topic_template":null,"has_children":false,"sort_order":null,"sort_ascending":null,"show_subcategory_list":true,"num_featured_topics":3,"default_view":null,"subcategory_list_style":"rows","default_top_period":"all","default_list_filter":"all","minimum_required_tags":0,"navigate_to_first_post_after_read":false,"custom_fields":{"has_chat_enabled":null,"activity_pub_enabled":null,"activity_pub_ready":null,"activity_pub_username":null,"activity_pub_name":null,"activity_pub_default_visibility":null,"activity_pub_publication_type":null,"activity_pub_post_object_type":null,"enable_unassigned_filter":null,"enable_accepted_answers":null},"allowed_tags":[],"allowed_tag_groups":[],"allow_global_tags":false,"read_only_banner":null,"form_template_ids":[],"activity_pub_enabled":false,"uploaded_logo":null,"uploaded_logo_dark":null,"uploaded_background":null,"uploaded_background_dark":null,"required_tag_groups":[],"can_edit":false}],"markdown_additional_options":{"chat":{"limited_pretty_text_features":["anchor","bbcode-block","bbcode-inline","code","category-hashtag","censored","chat-transcript","discourse-local-dates","emoji","emojiShortcuts","inlineEmoji","html-img","hashtag-autocomplete","mentions","unicodeUsernames","onebox","quotes","spoiler-alert","table","text-post-process","upload-protocol","watched-words"],"limited_pretty_text_markdown_rules":["autolink","list","backticks","newline","code","fence","image","table","linkify","link","strikethrough","blockquote","emphasis","replacements"],"hashtag_configurations":{"topic-composer":["category","tag","channel"],"chat-composer":["channel","category","tag"]}}},"hashtag_configurations":{"topic-composer":["category","tag","channel"],"chat-composer":["channel","category","tag"]},"hashtag_icons":{"category":"folder","tag":"tag","channel":"comment"},"displayed_about_plugin_stat_groups":["chat_messages"],"anonymous_default_navigation_menu_tags":[{"name":"official","description":"This is a theme or plugin built by the Discourse team","pm_only":false},{"name":"release-notes","description":"Release notes for Discourse beta and stable branches. See https://meta.discourse.org/t/198215 for more details on the Discourse release process.","pm_only":false}],"anonymous_sidebar_sections":[{"id":56,"title":"Community","links":[{"id":274,"name":"Topics","value":"/latest","icon":"layer-group","external":false,"full_reload":false,"segment":"primary"},{"id":275,"name":"My Posts","value":"/my/activity","icon":"user","external":false,"full_reload":true,"segment":"primary"},{"id":276,"name":"Review","value":"/review","icon":"flag","external":false,"full_reload":false,"segment":"primary"},{"id":277,"name":"Admin","value":"/admin","icon":"wrench","external":false,"full_reload":false,"segment":"primary"},{"id":279,"name":"Users","value":"/u","icon":"users","external":false,"full_reload":false,"segment":"secondary"},{"id":280,"name":"About","value":"/about","icon":"info-circle","external":false,"full_reload":false,"segment":"secondary"},{"id":281,"name":"FAQ","value":"/faq","icon":"question-circle","external":false,"full_reload":false,"segment":"secondary"},{"id":282,"name":"Groups","value":"/g","icon":"user-friends","external":false,"full_reload":false,"segment":"secondary"},{"id":283,"name":"Badges","value":"/badges","icon":"certificate","external":false,"full_reload":false,"segment":"secondary"},{"id":287,"name":"Leaderboard","value":"/leaderboard/7","icon":"trophy","external":false,"full_reload":false,"segment":"secondary"},{"id":290,"name":"Global Leaderboard","value":"/leaderboard","icon":"trophy","external":false,"full_reload":false,"segment":"secondary"},{"id":291,"name":"Topic Filter","value":"/filter","icon":"filter","external":false,"full_reload":false,"segment":"secondary"}],"slug":"community","public":true,"section_type":"community"}],"tos_url":"/tos","privacy_policy_url":"https://www.discourse.org/privacy","activity_pub_enabled":true,"activity_pub_publishing_enabled":true,"activity_pub_host":"meta.discourse.org","docs_path":"docs","default_gamification_leaderboard_id":1,"hosting_tier":"enterprise","archetypes":[{"id":"regular","name":"Regular Topic","options":[]},{"id":"banner","name":"Banner Topic","options":[]}],"user_fields":[{"id":2,"name":"Pronouns","description":"Gender Pronouns (he/him, she/her, they/them, etc.)","field_type":"text","editable":true,"required":false,"show_on_profile":true,"show_on_user_card":true,"searchable":false,"position":1}],"auth_providers":[{"name":"facebook","custom_url":null,"pretty_name_override":null,"title_override":null,"frame_width":580,"frame_height":400,"can_connect":true,"can_revoke":true,"icon":"fab-facebook"},{"name":"google_oauth2","custom_url":null,"pretty_name_override":null,"title_override":null,"frame_width":850,"frame_height":500,"can_connect":true,"can_revoke":true,"icon":null},{"name":"github","custom_url":null,"pretty_name_override":null,"title_override":null,"frame_width":null,"frame_height":null,"can_connect":true,"can_revoke":true,"icon":"fab-github"},{"name":"twitter","custom_url":null,"pretty_name_override":null,"title_override":null,"frame_width":null,"frame_height":null,"can_connect":true,"can_revoke":true,"icon":"fab-twitter"},{"name":"discord","custom_url":null,"pretty_name_override":null,"title_override":null,"frame_width":null,"frame_height":null,"can_connect":true,"can_revoke":true,"icon":"fab-discord"},{"name":"apple","custom_url":null,"pretty_name_override":null,"title_override":null,"frame_width":null,"frame_height":null,"can_connect":true,"can_revoke":true,"icon":"fab-apple"}]} \ No newline at end of file diff --git a/spec/jobs/regular/detect_translate_post_spec.rb b/spec/jobs/regular/detect_translate_post_spec.rb deleted file mode 100644 index 6dc60ba7..00000000 --- a/spec/jobs/regular/detect_translate_post_spec.rb +++ /dev/null @@ -1,174 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::DetectTranslatePost do - fab!(:post) - subject(:job) { described_class.new } - - let(:locales) { %w[en ja] } - - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.content_localization_supported_locales = locales.join("|") - end - - it "does nothing when translator is disabled" do - SiteSetting.discourse_ai_enabled = false - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).never - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ post_id: post.id }) - end - - it "does nothing when content translation is disabled" do - SiteSetting.ai_translation_enabled = false - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).never - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ post_id: post.id }) - end - - it "detects locale" do - allow(DiscourseAi::Translation::PostLocaleDetector).to receive(:detect_locale).with( - post, - ).and_return("zh_CN") - - job.execute({ post_id: post.id }) - end - - it "skips locale detection when post has a locale" do - post.update!(locale: "en") - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(post).never - - job.execute({ post_id: post.id }) - end - - it "skips bot posts" do - post.update!(user: Discourse.system_user) - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).never - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ post_id: post.id }) - end - - it "skips locale detection when no target languages are configured" do - SiteSetting.content_localization_supported_locales = "" - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).never - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ post_id: post.id }) - end - - it "skips translating to the post's language" do - post.update(locale: "en") - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "en").never - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "ja").once - - job.execute({ post_id: post.id }) - end - - it "skips translating if the post is already localized" do - post.update(locale: "en") - Fabricate(:post_localization, post:, locale: "ja") - - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ post_id: post.id }) - end - - it "does not translate to language of similar variant" do - post.update(locale: "en_GB") - Fabricate(:post_localization, post: post, locale: "ja_JP") - - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ post_id: post.id }) - end - - it "handles translation errors gracefully" do - post.update(locale: "en") - DiscourseAi::Translation::PostLocalizer.expects(:localize).raises( - StandardError.new("API error"), - ) - - expect { job.execute({ post_id: post.id }) }.not_to raise_error - end - - describe "with public content and PM limitations" do - fab!(:private_category) { Fabricate(:private_category, group: Group[:staff]) } - fab!(:private_topic) { Fabricate(:topic, category: private_category) } - fab!(:private_post) { Fabricate(:post, topic: private_topic) } - - fab!(:personal_pm_topic) { Fabricate(:private_message_topic) } - fab!(:personal_pm_post) { Fabricate(:post, topic: personal_pm_topic) } - - fab!(:group_pm_topic) do - Fabricate(:group_private_message_topic, recipient_group: Fabricate(:group)) - end - fab!(:group_pm_post) { Fabricate(:post, topic: group_pm_topic) } - - context "when ai_translation_backfill_limit_to_public_content is true" do - before { SiteSetting.ai_translation_backfill_limit_to_public_content = true } - - it "skips posts from restricted categories and PMs" do - DiscourseAi::Translation::PostLocaleDetector - .expects(:detect_locale) - .with(private_post) - .never - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(private_post, any_parameters) - .never - job.execute({ post_id: private_post.id }) - - DiscourseAi::Translation::PostLocaleDetector - .expects(:detect_locale) - .with(personal_pm_post) - .never - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(personal_pm_post, any_parameters) - .never - job.execute({ post_id: personal_pm_post.id }) - - DiscourseAi::Translation::PostLocaleDetector - .expects(:detect_locale) - .with(group_pm_post) - .never - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(group_pm_post, any_parameters) - .never - job.execute({ post_id: group_pm_post.id }) - end - end - - context "when ai_translation_backfill_limit_to_public_content is false" do - before { SiteSetting.ai_translation_backfill_limit_to_public_content = false } - - it "processes posts from private categories and group PMs but skips personal PMs" do - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(private_post).once - job.execute({ post_id: private_post.id }) - - DiscourseAi::Translation::PostLocaleDetector - .expects(:detect_locale) - .with(group_pm_post) - .once - job.execute({ post_id: group_pm_post.id }) - - DiscourseAi::Translation::PostLocaleDetector - .expects(:detect_locale) - .with(personal_pm_post) - .never - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(personal_pm_post, any_parameters) - .never - job.execute({ post_id: personal_pm_post.id }) - end - end - end -end diff --git a/spec/jobs/regular/detect_translate_topic_spec.rb b/spec/jobs/regular/detect_translate_topic_spec.rb deleted file mode 100644 index e867c9e0..00000000 --- a/spec/jobs/regular/detect_translate_topic_spec.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::DetectTranslateTopic do - fab!(:topic) - subject(:job) { described_class.new } - - let(:locales) { %w[en ja] } - - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.content_localization_supported_locales = locales.join("|") - end - - it "does nothing when translator is disabled" do - SiteSetting.discourse_ai_enabled = false - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).never - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ topic_id: topic.id }) - end - - it "does nothing when content translation is disabled" do - SiteSetting.ai_translation_enabled = false - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).never - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ topic_id: topic.id }) - end - - it "detects locale" do - allow(DiscourseAi::Translation::TopicLocaleDetector).to receive(:detect_locale).with( - topic, - ).and_return("zh_CN") - DiscourseAi::Translation::TopicLocalizer.expects(:localize).twice - - job.execute({ topic_id: topic.id }) - end - - it "skips locale detection when topic has a locale" do - topic.update!(locale: "en") - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(topic).never - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "ja").once - - job.execute({ topic_id: topic.id }) - end - - it "skips bot topics" do - topic.update!(user: Discourse.system_user) - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ topic_id: topic.id }) - end - - it "does not get locale or translate when no target languages are configured" do - SiteSetting.content_localization_supported_locales = "" - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).never - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ topic_id: topic.id }) - end - - it "skips translating to the topic's language" do - topic.update(locale: "en") - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "en").never - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "ja").once - - job.execute({ topic_id: topic.id }) - end - - it "skips translating if the topic is already localized" do - topic.update(locale: "en") - Fabricate(:topic_localization, topic:, locale: "ja") - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ topic_id: topic.id }) - end - - it "does not translate to language of similar variant" do - topic.update(locale: "en_GB") - Fabricate(:topic_localization, topic:, locale: "ja_JP") - - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ topic_id: topic.id }) - end - - it "handles translation errors gracefully" do - topic.update(locale: "en") - DiscourseAi::Translation::TopicLocalizer.expects(:localize).raises( - StandardError.new("API error"), - ) - - expect { job.execute({ topic_id: topic.id }) }.not_to raise_error - end - - describe "with public content and PM limitations" do - fab!(:private_category) { Fabricate(:private_category, group: Group[:staff]) } - fab!(:private_topic) { Fabricate(:topic, category: private_category) } - - fab!(:personal_pm_topic) { Fabricate(:private_message_topic) } - - fab!(:group_pm_topic) do - Fabricate(:group_private_message_topic, recipient_group: Fabricate(:group)) - end - - context "when ai_translation_backfill_limit_to_public_content is true" do - before { SiteSetting.ai_translation_backfill_limit_to_public_content = true } - - it "skips topics from restricted categories and PMs" do - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(private_topic) - .never - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(private_topic, any_parameters) - .never - job.execute({ topic_id: private_topic.id }) - - # Skip personal PMs - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(personal_pm_topic) - .never - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(personal_pm_topic, any_parameters) - .never - job.execute({ topic_id: personal_pm_topic.id }) - - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(group_pm_topic) - .never - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(group_pm_topic, any_parameters) - .never - - job.execute({ topic_id: group_pm_topic.id }) - end - end - - context "when ai_translation_backfill_limit_to_public_content is false" do - before { SiteSetting.ai_translation_backfill_limit_to_public_content = false } - - it "processes topics from private categories and group PMs but skips personal PMs" do - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(private_topic) - .once - job.execute({ topic_id: private_topic.id }) - - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(group_pm_topic) - .once - job.execute({ topic_id: group_pm_topic.id }) - - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(personal_pm_topic) - .never - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(personal_pm_topic, any_parameters) - .never - job.execute({ topic_id: personal_pm_topic.id }) - end - end - end -end diff --git a/spec/jobs/regular/digest_rag_upload_spec.rb b/spec/jobs/regular/digest_rag_upload_spec.rb deleted file mode 100644 index 75e4c055..00000000 --- a/spec/jobs/regular/digest_rag_upload_spec.rb +++ /dev/null @@ -1,118 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::DigestRagUpload do - fab!(:persona) { Fabricate(:ai_persona) } - fab!(:upload) { Fabricate(:upload, extension: "txt") } - fab!(:image_upload) { Fabricate(:upload, extension: "png") } - let(:document_file) { StringIO.new("some text" * 200) } - - fab!(:cloudflare_embedding_def) - let(:expected_embedding) { [0.0038493] * cloudflare_embedding_def.dimensions } - - let(:document_with_metadata) { plugin_file_from_fixtures("doc_with_metadata.txt", "rag") } - - let(:parsed_document_with_metadata) do - plugin_file_from_fixtures("parsed_doc_with_metadata.txt", "rag") - end - - let(:upload_with_metadata) do - UploadCreator.new(document_with_metadata, "document.txt").create_for(Discourse.system_user.id) - end - - before do - enable_current_plugin - - SiteSetting.ai_embeddings_selected_model = cloudflare_embedding_def.id - SiteSetting.ai_embeddings_enabled = true - SiteSetting.authorized_extensions = "txt" - - WebMock.stub_request(:post, cloudflare_embedding_def.url).to_return( - status: 200, - body: JSON.dump(expected_embedding), - ) - end - - describe "#execute" do - context "when processing an image upload" do - it "will reject the indexing if the site setting is not enabled" do - SiteSetting.ai_rag_images_enabled = false - - expect { - described_class.new.execute( - upload_id: image_upload.id, - target_id: persona.id, - target_type: persona.class.to_s, - ) - }.to raise_error(Discourse::InvalidAccess) - end - end - context "when processing an upload containing metadata" do - it "correctly splits on metadata boundary" do - # be explicit here about chunking strategy - persona.update!(rag_chunk_tokens: 100, rag_chunk_overlap_tokens: 10) - - described_class.new.execute( - upload_id: upload_with_metadata.id, - target_id: persona.id, - target_type: persona.class.to_s, - ) - - parsed = +"" - first = true - RagDocumentFragment - .where(upload: upload_with_metadata) - .order(:fragment_number) - .each do |fragment| - parsed << "\n\n" if !first - parsed << "metadata: #{fragment.metadata}\n" - parsed << "number: #{fragment.fragment_number}\n" - parsed << fragment.fragment - first = false - end - - # to rebuild parsed - #File.write("/tmp/testing", parsed) - - expect(parsed).to eq(parsed_document_with_metadata.read) - end - end - context "when processing an upload for the first time" do - before { File.expects(:open).returns(document_file) } - - it "splits an upload into chunks" do - subject.execute( - upload_id: upload.id, - target_id: persona.id, - target_type: persona.class.to_s, - ) - - created_fragment = RagDocumentFragment.last - - expect(created_fragment).to be_present - expect(created_fragment.fragment).to be_present - expect(created_fragment.fragment_number).to eq(2) - end - - it "queue jobs to generate embeddings for each fragment" do - expect { - subject.execute( - upload_id: upload.id, - target_id: persona.id, - target_type: persona.class.to_s, - ) - }.to change(Jobs::GenerateRagEmbeddings.jobs, :size).by(1) - end - end - - it "doesn't generate new fragments if we already processed the upload" do - Fabricate(:rag_document_fragment, upload: upload, target: persona) - - previous_count = RagDocumentFragment.where(upload: upload, target: persona).count - - subject.execute(upload_id: upload.id, target_id: persona.id, target_type: persona.class.to_s) - updated_count = RagDocumentFragment.where(upload: upload, target: persona).count - - expect(updated_count).to eq(previous_count) - end - end -end diff --git a/spec/jobs/regular/fast_track_topic_gist_spec.rb b/spec/jobs/regular/fast_track_topic_gist_spec.rb deleted file mode 100644 index 82ab2c82..00000000 --- a/spec/jobs/regular/fast_track_topic_gist_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::FastTrackTopicGist do - describe "#execute" do - fab!(:topic_1) { Fabricate(:topic) } - fab!(:post_1) { Fabricate(:post, topic: topic_1, post_number: 1) } - fab!(:post_2) { Fabricate(:post, topic: topic_1, post_number: 2) } - - before do - enable_current_plugin - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - SiteSetting.ai_summary_gists_enabled = true - end - - context "when the topic has a gist" do - fab!(:ai_gist) do - Fabricate( - :topic_ai_gist, - target: topic_1, - original_content_sha: AiSummary.build_sha("12"), - created_at: 10.minutes.ago, - ) - end - - let(:updated_gist) { "They updated me :(" } - - context "when it's up to date" do - it "does nothing" do - DiscourseAi::Completions::Llm.with_prepared_responses([updated_gist]) do - subject.execute(topic_id: topic_1.id) - end - - gist = AiSummary.gist.find_by(target: topic_1) - expect(AiSummary.gist.where(target: topic_1).count).to eq(1) - expect(gist.summarized_text).not_to eq(updated_gist) - end - end - - context "when it's outdated" do - before { Fabricate(:post, topic: topic_1, post_number: 3) } - - it "regenerates the gist using the latest data" do - DiscourseAi::Completions::Llm.with_prepared_responses([updated_gist]) do - subject.execute(topic_id: topic_1.id) - end - - gist = AiSummary.gist.find_by(target: topic_1) - expect(AiSummary.gist.where(target: topic_1).count).to eq(1) - expect(gist.summarized_text).to eq(updated_gist) - expect(gist.original_content_sha).to eq(AiSummary.build_sha("123")) - end - - it "does nothing if the gist was created less than 5 minutes ago" do - ai_gist.update!(created_at: 2.minutes.ago) - - DiscourseAi::Completions::Llm.with_prepared_responses([updated_gist]) do - subject.execute(topic_id: topic_1.id) - end - - gist = AiSummary.gist.find_by(target: topic_1) - expect(AiSummary.gist.where(target: topic_1).count).to eq(1) - expect(gist.summarized_text).not_to eq(updated_gist) - expect(gist.original_content_sha).to eq(AiSummary.build_sha("12")) - end - end - end - - context "when the topic doesn't have a hot topic score" do - it "creates gist" do - subject.execute(topic_id: topic_1.id) - - gist = AiSummary.gist.find_by(target: topic_1) - expect(gist).to be_present - end - end - - context "when the topic has a hot topic score but no gist" do - before { TopicHotScore.create!(topic_id: topic_1.id, score: 0.1) } - - it "creates gist" do - subject.execute(topic_id: topic_1.id) - - gist = AiSummary.gist.find_by(target: topic_1) - expect(gist).to be_present - end - end - end -end diff --git a/spec/jobs/regular/generate_inferred_concepts_spec.rb b/spec/jobs/regular/generate_inferred_concepts_spec.rb deleted file mode 100644 index fc809518..00000000 --- a/spec/jobs/regular/generate_inferred_concepts_spec.rb +++ /dev/null @@ -1,170 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::GenerateInferredConcepts do - fab!(:topic) - fab!(:post) - fab!(:concept) { Fabricate(:inferred_concept, name: "programming") } - - before do - enable_current_plugin - SiteSetting.inferred_concepts_enabled = true - end - - describe "#execute" do - it "does nothing with blank item_ids" do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).not_to receive( - :match_topic_to_concepts, - ) - - subject.execute(item_type: "topics", item_ids: []) - subject.execute(item_type: "topics", item_ids: nil) - end - - it "does nothing with blank item_type" do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).not_to receive( - :match_topic_to_concepts, - ) - - subject.execute(item_type: "", item_ids: [topic.id]) - subject.execute(item_type: nil, item_ids: [topic.id]) - end - - it "validates item_type to be topics or posts" do - allow(Rails.logger).to receive(:error).with(/Invalid item_type/) - - subject.execute(item_type: "invalid", item_ids: [1]) - end - - context "with topics" do - it "processes topics in match_only mode" do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :match_topic_to_concepts, - ).with(topic) - - subject.execute(item_type: "topics", item_ids: [topic.id], match_only: true) - end - - it "processes topics in generation mode" do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :generate_concepts_from_topic, - ).with(topic) - - subject.execute(item_type: "topics", item_ids: [topic.id], match_only: false) - end - - it "handles topics that don't exist" do - # Non-existent IDs should be silently skipped (no error expected) - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).not_to receive( - :match_topic_to_concepts, - ) - - subject.execute( - item_type: "topics", - item_ids: [999_999], # non-existent ID - match_only: true, - ) - end - - it "processes multiple topics" do - topic2 = Fabricate(:topic) - - manager_instance = instance_double(DiscourseAi::InferredConcepts::Manager) - allow(DiscourseAi::InferredConcepts::Manager).to receive(:new).and_return(manager_instance) - - allow(manager_instance).to receive(:match_topic_to_concepts).with(topic) - allow(manager_instance).to receive(:match_topic_to_concepts).with(topic2) - - subject.execute(item_type: "topics", item_ids: [topic.id, topic2.id], match_only: true) - end - - it "processes topics in batches" do - topics = Array.new(5) { Fabricate(:topic) } - topic_ids = topics.map(&:id) - - # Should process in batches of 3 - allow(Topic).to receive(:where).with(id: topic_ids[0..2]).and_call_original - allow(Topic).to receive(:where).with(id: topic_ids[3..4]).and_call_original - - subject.execute(item_type: "topics", item_ids: topic_ids, batch_size: 3, match_only: true) - end - end - - context "with posts" do - it "processes posts in match_only mode" do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :match_post_to_concepts, - ).with(post) - - subject.execute(item_type: "posts", item_ids: [post.id], match_only: true) - end - - it "processes posts in generation mode" do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :generate_concepts_from_post, - ).with(post) - - subject.execute(item_type: "posts", item_ids: [post.id], match_only: false) - end - - it "handles posts that don't exist" do - # Non-existent IDs should be silently skipped (no error expected) - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).not_to receive( - :match_post_to_concepts, - ) - - subject.execute( - item_type: "posts", - item_ids: [999_999], # non-existent ID - match_only: true, - ) - end - - it "processes multiple posts" do - post2 = Fabricate(:post) - - manager_instance = instance_double(DiscourseAi::InferredConcepts::Manager) - allow(DiscourseAi::InferredConcepts::Manager).to receive(:new).and_return(manager_instance) - - allow(manager_instance).to receive(:match_post_to_concepts).with(post) - allow(manager_instance).to receive(:match_post_to_concepts).with(post2) - - subject.execute(item_type: "posts", item_ids: [post.id, post2.id], match_only: true) - end - end - - it "handles exceptions during processing" do - allow_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :match_topic_to_concepts, - ).and_raise(StandardError.new("Test error")) - - allow(Rails.logger).to receive(:error).with( - /Error generating concepts from topic #{topic.id}/, - ) - - subject.execute(item_type: "topics", item_ids: [topic.id], match_only: true) - end - - it "uses default batch size of 100" do - topics = Array.new(150) { Fabricate(:topic) } - topic_ids = topics.map(&:id) - - # Should process in batches of 100 - allow(Topic).to receive(:where).with(id: topic_ids[0..99]).and_call_original - allow(Topic).to receive(:where).with(id: topic_ids[100..149]).and_call_original - - subject.execute(item_type: "topics", item_ids: topic_ids, match_only: true) - end - - it "respects custom batch size" do - topics = Array.new(5) { Fabricate(:topic) } - topic_ids = topics.map(&:id) - - # Should process in batches of 2 - allow(Topic).to receive(:where).with(id: topic_ids[0..1]).and_call_original - allow(Topic).to receive(:where).with(id: topic_ids[2..3]).and_call_original - allow(Topic).to receive(:where).with(id: topic_ids[4..4]).and_call_original - - subject.execute(item_type: "topics", item_ids: topic_ids, batch_size: 2, match_only: true) - end - end -end diff --git a/spec/jobs/regular/generate_rag_embeddings_spec.rb b/spec/jobs/regular/generate_rag_embeddings_spec.rb deleted file mode 100644 index 94533ea9..00000000 --- a/spec/jobs/regular/generate_rag_embeddings_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::GenerateRagEmbeddings do - before { enable_current_plugin } - - describe "#execute" do - fab!(:vector_def) { Fabricate(:embedding_definition) } - - let(:expected_embedding) { [0.0038493] * vector_def.dimensions } - - fab!(:ai_persona) - - let(:rag_document_fragment_1) { Fabricate(:rag_document_fragment, target: ai_persona) } - let(:rag_document_fragment_2) { Fabricate(:rag_document_fragment, target: ai_persona) } - - before do - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_enabled = true - - rag_document_fragment_1 - rag_document_fragment_2 - - WebMock.stub_request(:post, vector_def.url).to_return( - status: 200, - body: JSON.dump(expected_embedding), - ) - end - - it "generates a new vector for each fragment" do - expected_embeddings = 2 - - subject.execute(fragment_ids: [rag_document_fragment_1.id, rag_document_fragment_2.id]) - - embeddings_count = - DB.query_single( - "SELECT COUNT(*) from #{DiscourseAi::Embeddings::Schema::RAG_DOCS_TABLE}", - ).first - - expect(embeddings_count).to eq(expected_embeddings) - end - - describe "Publishing progress updates" do - it "sends an update through mb after a batch finishes" do - updates = - MessageBus.track_publish("/discourse-ai/rag/#{rag_document_fragment_1.upload_id}") do - subject.execute(fragment_ids: [rag_document_fragment_1.id]) - end - - upload_index_stats = updates.last.data - - expect(upload_index_stats[:total]).to eq(1) - expect(upload_index_stats[:indexed]).to eq(1) - expect(upload_index_stats[:left]).to eq(0) - end - end - end -end diff --git a/spec/jobs/regular/localize_categories_spec.rb b/spec/jobs/regular/localize_categories_spec.rb deleted file mode 100644 index f0d5e886..00000000 --- a/spec/jobs/regular/localize_categories_spec.rb +++ /dev/null @@ -1,174 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::LocalizeCategories do - subject(:job) { described_class.new } - - def localize_all_categories(*locales) - Category.all.each do |category| - locales.each { |locale| Fabricate(:category_localization, category:, locale:, name: "x") } - end - end - - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.content_localization_supported_locales = "pt_BR|zh_CN" - - Jobs.run_immediately! - end - - it "does nothing when DiscourseAi::Translation::CategoryLocalizer is disabled" do - SiteSetting.discourse_ai_enabled = false - - DiscourseAi::Translation::CategoryLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when ai_translation_enabled is disabled" do - SiteSetting.ai_translation_enabled = false - - DiscourseAi::Translation::CategoryLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when no target languages are configured" do - SiteSetting.content_localization_supported_locales = "" - - DiscourseAi::Translation::CategoryLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when no categories exist" do - Category.destroy_all - - DiscourseAi::Translation::CategoryLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when the limit is zero" do - DiscourseAi::Translation::CategoryLocalizer.expects(:localize).never - - expect { job.execute({}) }.to raise_error(Discourse::InvalidParameters, /limit/) - job.execute({ limit: 0 }) - end - - it "translates categories to the configured locales" do - Category.update_all(locale: "en") - number_of_categories = Category.count - - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(is_a(Category), "pt_BR") - .times(number_of_categories) - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(is_a(Category), "zh_CN") - .times(number_of_categories) - - job.execute({ limit: 10 }) - end - - it "limits the number of localizations" do - SiteSetting.content_localization_supported_locales = "pt" - - 6.times { Fabricate(:category) } - Category.update_all(locale: "en") - - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(is_a(Category), "pt") - .times(5) - - job.execute({ limit: 5 }) - end - - it "skips categories that already have localizations" do - localize_all_categories("pt", "zh_CN") - - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(is_a(Category), "pt_BR") - .never - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(is_a(Category), "zh_CN") - .never - - job.execute({ limit: 10 }) - end - - it "handles translation errors gracefully" do - localize_all_categories("pt", "zh_CN") - - category1 = Fabricate(:category, name: "First", description: "First description", locale: "en") - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(category1, "pt_BR") - .once - .raises(StandardError.new("API error")) - DiscourseAi::Translation::CategoryLocalizer.expects(:localize).with(category1, "zh_CN").once - - expect { job.execute({ limit: 10 }) }.not_to raise_error - end - - it "skips read-restricted categories when configured" do - SiteSetting.ai_translation_backfill_limit_to_public_content = true - - category1 = Fabricate(:category, name: "Public Category", read_restricted: false, locale: "en") - category2 = Fabricate(:category, name: "Private Category", read_restricted: true, locale: "en") - - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(category1, any_parameters) - .twice - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(category2, any_parameters) - .never - - job.execute({ limit: 10 }) - end - - it "skips creating localizations in the same language as the category's locale" do - Category.update_all(locale: "pt") - - DiscourseAi::Translation::CategoryLocalizer.expects(:localize).with(is_a(Category), "pt").never - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(is_a(Category), "zh_CN") - .times(Category.count) - - job.execute({ limit: 10 }) - end - - it "deletes existing localizations that match the category's locale" do - # update all categories to portuguese - Category.update_all(locale: "pt") - - localize_all_categories("pt", "zh_CN") - - expect { job.execute({ limit: 10 }) }.to change { - CategoryLocalization.exists?(locale: "pt") - }.from(true).to(false) - end - - it "doesn't process categories with nil locale" do - # Add a category with nil locale - nil_locale_category = Fabricate(:category, name: "No Locale", locale: nil) - - # Make sure our query for categories with non-null locales excludes it - DiscourseAi::Translation::CategoryLocalizer - .expects(:localize) - .with(nil_locale_category, any_parameters) - .never - - job.execute({ limit: 10 }) - end -end diff --git a/spec/jobs/regular/localize_posts_spec.rb b/spec/jobs/regular/localize_posts_spec.rb deleted file mode 100644 index 8c54839c..00000000 --- a/spec/jobs/regular/localize_posts_spec.rb +++ /dev/null @@ -1,231 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::LocalizePosts do - fab!(:post) - subject(:job) { described_class.new } - - let(:locales) { %w[en ja de] } - - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.content_localization_supported_locales = locales.join("|") - SiteSetting.ai_translation_backfill_hourly_rate = 100 - SiteSetting.ai_translation_backfill_max_age_days = 100 - end - - it "does nothing when translator is disabled" do - SiteSetting.discourse_ai_enabled = false - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when ai_translation_enabled is disabled" do - SiteSetting.ai_translation_enabled = false - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when no target languages are configured" do - SiteSetting.content_localization_supported_locales = "" - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when ai_translation_backfill_hourly_rate is 0" do - SiteSetting.ai_translation_backfill_hourly_rate = 0 - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when there are no posts to translate" do - Post.destroy_all - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "skips bot posts" do - post.update!(locale: "es", user: Discourse.system_user) - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "handles translation errors gracefully" do - post.update(locale: "es") - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(post, "en") - .raises(StandardError.new("API error")) - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "ja").once - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "de").once - - expect { job.execute({ limit: 10 }) }.not_to raise_error - end - - it "logs a summary after translation" do - post.update(locale: "es") - DiscourseAi::Translation::PostLocalizer.stubs(:localize) - DiscourseAi::Translation::VerboseLogger.expects(:log).with(includes("Translated 1 posts to en")) - DiscourseAi::Translation::VerboseLogger.expects(:log).with(includes("Translated 1 posts to ja")) - DiscourseAi::Translation::VerboseLogger.expects(:log).with(includes("Translated 1 posts to de")) - - job.execute({ limit: 10 }) - end - - context "for translation scenarios" do - it "scenario 1: skips post when locale is not set" do - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "scenario 2: localizes post with locale 'es' when localizations for en/ja/de do not exist" do - post = Fabricate(:post, locale: "es") - - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "en").once - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "ja").once - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "de").once - - job.execute({ limit: 10 }) - end - - it "scenario 3: localizes post with locale 'en' when ja/de localization do not exist" do - post = Fabricate(:post, locale: "en") - - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "ja").once - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "de").once - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(post, "en").never - - job.execute({ limit: 10 }) - end - - it "scenario 4: skips post with locale 'en' if all localizations exist" do - post = Fabricate(:post, locale: "en") - Fabricate(:post_localization, post: post, locale: "ja") - Fabricate(:post_localization, post: post, locale: "de") - - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "scenario 5: skips posts that already have localizations in similar language variant" do - post = Fabricate(:post, locale: "en") - Fabricate(:post_localization, post: post, locale: "ja_JP") - Fabricate(:post_localization, post: post, locale: "de_DE") - - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "scenario 6: skips posts with variant 'en_GB' when localizations for ja/de exist" do - post = Fabricate(:post, locale: "en_GB") - Fabricate(:post_localization, post: post, locale: "ja_JP") - Fabricate(:post_localization, post: post, locale: "de_DE") - - DiscourseAi::Translation::PostLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - end - - describe "with public content limitation" do - fab!(:private_category) { Fabricate(:private_category, group: Group[:staff]) } - fab!(:private_topic) { Fabricate(:topic, category: private_category) } - fab!(:private_post) { Fabricate(:post, topic: private_topic, locale: "es") } - - fab!(:public_post) { Fabricate(:post, locale: "es") } - - fab!(:personal_pm_topic) { Fabricate(:private_message_topic) } - fab!(:personal_pm_post) { Fabricate(:post, topic: personal_pm_topic, locale: "es") } - - fab!(:group) - fab!(:group_pm_topic) { Fabricate(:group_private_message_topic, recipient_group: group) } - fab!(:group_pm_post) { Fabricate(:post, topic: group_pm_topic, locale: "es") } - - before { SiteSetting.content_localization_supported_locales = "ja" } - - context "when ai_translation_backfill_limit_to_public_content is true" do - before { SiteSetting.ai_translation_backfill_limit_to_public_content = true } - - it "only processes posts from public categories" do - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(public_post, "ja").once - - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(private_post, any_parameters) - .never - - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(personal_pm_post, any_parameters) - .never - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(group_pm_post, any_parameters) - .never - - job.execute({ limit: 10 }) - end - end - - context "when ai_translation_backfill_limit_to_public_content is false" do - before { SiteSetting.ai_translation_backfill_limit_to_public_content = false } - - it "processes public posts and group PMs but not personal PMs" do - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(public_post, "ja").once - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(private_post, "ja").once - - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(group_pm_post, "ja").once - - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(personal_pm_post, any_parameters) - .never - - job.execute({ limit: 10 }) - end - end - end - - describe "with max age limit" do - fab!(:old_post) { Fabricate(:post, locale: "es", created_at: 10.days.ago) } - fab!(:new_post) { Fabricate(:post, locale: "es", created_at: 2.days.ago) } - - before do - SiteSetting.ai_translation_backfill_max_age_days = 5 - SiteSetting.content_localization_supported_locales = "ja" - end - - it "only processes posts within the age limit" do - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(new_post, "ja").once - - DiscourseAi::Translation::PostLocalizer - .expects(:localize) - .with(old_post, any_parameters) - .never - - job.execute({ limit: 10 }) - end - - it "processes all posts when setting is large" do - SiteSetting.ai_translation_backfill_max_age_days = 1000 - - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(new_post, "ja").once - - DiscourseAi::Translation::PostLocalizer.expects(:localize).with(old_post, "ja").once - - job.execute({ limit: 10 }) - end - end -end diff --git a/spec/jobs/regular/localize_topics_spec.rb b/spec/jobs/regular/localize_topics_spec.rb deleted file mode 100644 index d2f34add..00000000 --- a/spec/jobs/regular/localize_topics_spec.rb +++ /dev/null @@ -1,233 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::LocalizeTopics do - fab!(:topic) - subject(:job) { described_class.new } - - let(:locales) { %w[en ja de] } - - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.content_localization_supported_locales = locales.join("|") - SiteSetting.ai_translation_backfill_hourly_rate = 100 - SiteSetting.ai_translation_backfill_max_age_days = 100 - end - - it "does nothing when translator is disabled" do - SiteSetting.discourse_ai_enabled = false - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when ai_translation_enabled is disabled" do - SiteSetting.ai_translation_enabled = false - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when no target languages are configured" do - SiteSetting.content_localization_supported_locales = "" - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "does nothing when there are no topics to translate" do - Topic.destroy_all - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "skips topics that already have localizations" do - Topic.all.each do |topic| - Fabricate(:topic_localization, topic:, locale: "en") - Fabricate(:topic_localization, topic:, locale: "ja") - end - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "skips bot topics" do - topic.update!(user: Discourse.system_user) - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "en").never - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "ja").never - - job.execute({ limit: 10 }) - end - - it "handles translation errors gracefully" do - topic.update(locale: "es") - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(topic, "en") - .raises(StandardError.new("API error")) - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "ja").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "de").once - - expect { job.execute({ limit: 10 }) }.not_to raise_error - end - - it "logs a summary after translation" do - topic.update(locale: "es") - DiscourseAi::Translation::TopicLocalizer.stubs(:localize) - DiscourseAi::Translation::VerboseLogger.expects(:log).with( - includes("Translated 1 topics to en"), - ) - DiscourseAi::Translation::VerboseLogger.expects(:log).with( - includes("Translated 1 topics to ja"), - ) - DiscourseAi::Translation::VerboseLogger.expects(:log).with( - includes("Translated 1 topics to de"), - ) - - job.execute({ limit: 10 }) - end - - context "for translation scenarios" do - it "scenario 1: skips topic when locale is not set" do - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "scenario 2: returns topic with locale 'es' if localizations for en/ja/de do not exist" do - topic = Fabricate(:topic, locale: "es") - - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "en").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "ja").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "de").once - - job.execute({ limit: 10 }) - end - - it "scenario 3: returns topic with locale 'en' if ja/de localization does not exist" do - topic = Fabricate(:topic, locale: "en") - - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "ja").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "de").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(topic, "en").never - - job.execute({ limit: 10 }) - end - - it "scenario 4: skips topic with locale 'en' if all localizations exist" do - topic = Fabricate(:topic, locale: "en") - Fabricate(:topic_localization, topic: topic, locale: "ja") - Fabricate(:topic_localization, topic: topic, locale: "de") - - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - - it "scenario 5: skips topic that already have localizations in similar language variant" do - topic = Fabricate(:topic, locale: "en") - Fabricate(:topic_localization, topic: topic, locale: "ja_JP") - Fabricate(:topic_localization, topic: topic, locale: "de_DE") - - DiscourseAi::Translation::TopicLocalizer.expects(:localize).never - - job.execute({ limit: 10 }) - end - end - - describe "with public content limitation" do - fab!(:private_category) { Fabricate(:private_category, group: Group[:staff]) } - fab!(:private_topic) { Fabricate(:topic, category: private_category, locale: "es") } - fab!(:public_topic) { Fabricate(:topic, locale: "es") } - - fab!(:personal_pm_topic) { Fabricate(:private_message_topic, locale: "es") } - - fab!(:group_pm_topic) do - Fabricate(:group_private_message_topic, recipient_group: Fabricate(:group), locale: "es") - end - - before { SiteSetting.content_localization_supported_locales = "ja" } - - context "when ai_translation_backfill_limit_to_public_content is true" do - before { SiteSetting.ai_translation_backfill_limit_to_public_content = true } - - it "only processes topics from public categories" do - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(public_topic, "ja").once - - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(private_topic, any_parameters) - .never - - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(personal_pm_topic, any_parameters) - .never - - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(group_pm_topic, any_parameters) - .never - - job.execute({ limit: 10 }) - end - end - - context "when ai_translation_backfill_limit_to_public_content is false" do - before { SiteSetting.ai_translation_backfill_limit_to_public_content = false } - - it "processes public topics, private topics and group PMs but not personal PMs" do - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(public_topic, "ja").once - - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(private_topic, "ja").once - - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(group_pm_topic, "ja").once - - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(personal_pm_topic, any_parameters) - .never - - job.execute({ limit: 10 }) - end - end - end - - describe "with max age limit" do - fab!(:old_topic) { Fabricate(:topic, locale: "es", created_at: 10.days.ago) } - fab!(:new_topic) { Fabricate(:topic, locale: "es", created_at: 2.days.ago) } - - before { SiteSetting.ai_translation_backfill_max_age_days = 5 } - - it "only processes topics within the age limit" do - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(new_topic, "en").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(new_topic, "ja").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(new_topic, "de").once - - DiscourseAi::Translation::TopicLocalizer - .expects(:localize) - .with(old_topic, any_parameters) - .never - - job.execute({ limit: 10 }) - end - - it "processes all topics when setting is more than the post age" do - SiteSetting.ai_translation_backfill_max_age_days = 100 - - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(new_topic, "en").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(new_topic, "ja").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(new_topic, "de").once - - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(old_topic, "en").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(old_topic, "ja").once - DiscourseAi::Translation::TopicLocalizer.expects(:localize).with(old_topic, "de").once - - job.execute({ limit: 10 }) - end - end -end diff --git a/spec/jobs/regular/manage_embedding_def_search_index_spec.rb b/spec/jobs/regular/manage_embedding_def_search_index_spec.rb deleted file mode 100644 index 21d3c275..00000000 --- a/spec/jobs/regular/manage_embedding_def_search_index_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::ManageEmbeddingDefSearchIndex do - fab!(:embedding_definition) - - before { enable_current_plugin } - - describe "#execute" do - context "when there is no embedding def" do - it "does nothing" do - invalid_id = 999_999_999 - - subject.execute(id: invalid_id) - - expect( - DiscourseAi::Embeddings::Schema.correctly_indexed?( - EmbeddingDefinition.new(id: invalid_id), - ), - ).to eq(false) - end - end - - context "when the embedding def is fresh" do - it "creates the indexes" do - subject.execute(id: embedding_definition.id) - - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_definition)).to eq(true) - end - - it "creates them only once" do - subject.execute(id: embedding_definition.id) - subject.execute(id: embedding_definition.id) - - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_definition)).to eq(true) - end - - context "when one of the idxs is missing" do - it "automatically recovers by creating it" do - DB.exec <<~SQL - CREATE INDEX IF NOT EXISTS ai_topics_embeddings_#{embedding_definition.id}_1_search_bit ON ai_topics_embeddings - USING hnsw ((binary_quantize(embeddings)::bit(#{embedding_definition.dimensions})) bit_hamming_ops) - WHERE model_id = #{embedding_definition.id} AND strategy_id = 1; - SQL - - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_definition)).to eq( - false, - ) - - subject.execute(id: embedding_definition.id) - - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_definition)).to eq( - true, - ) - end - end - end - end -end diff --git a/spec/jobs/regular/stream_composer_helper_spec.rb b/spec/jobs/regular/stream_composer_helper_spec.rb deleted file mode 100644 index e322254c..00000000 --- a/spec/jobs/regular/stream_composer_helper_spec.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::StreamComposerHelper do - subject(:job) { described_class.new } - - before do - enable_current_plugin - assign_fake_provider_to(:ai_helper_model) - end - - describe "#execute" do - let!(:input) { "I liek to eet pie fur brakefast becuz it is delishus." } - fab!(:user) { Fabricate(:leader) } - - before do - Group.find(Group::AUTO_GROUPS[:trust_level_3]).add(user) - SiteSetting.ai_helper_enabled = true - end - - describe "validates params" do - let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } - - it "does nothing if there is no user" do - channel = "/some/channel" - messages = - MessageBus.track_publish(channel) do - job.execute( - user_id: nil, - text: input, - prompt: mode, - force_default_locale: false, - client_id: "123", - progress_channel: channel, - ) - end - - expect(messages).to be_empty - end - - it "does nothing if there is no text" do - channel = "/some/channel" - messages = - MessageBus.track_publish(channel) do - job.execute( - user_id: user.id, - text: nil, - prompt: mode, - force_default_locale: false, - client_id: "123", - progress_channel: channel, - ) - end - - expect(messages).to be_empty - end - end - - context "when all params are provided" do - let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } - - it "publishes updates with a partial result" do - proofread_result = "I like to eat pie for breakfast because it is delicious." - channel = "/channel/123" - - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_result]) do - messages = - MessageBus.track_publish(channel) do - job.execute( - user_id: user.id, - text: input, - prompt: mode, - force_default_locale: true, - client_id: "123", - progress_channel: channel, - ) - end - - partial_result_update = messages.first.data - expect(partial_result_update[:done]).to eq(false) - expect(partial_result_update[:result]).to eq(proofread_result) - end - end - - it "publishes a final update to signal we're done" do - proofread_result = "I like to eat pie for breakfast because it is delicious." - channel = "/channel/123" - - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_result]) do - messages = - MessageBus.track_publish(channel) do - job.execute( - user_id: user.id, - text: input, - prompt: mode, - force_default_locale: true, - client_id: "123", - progress_channel: channel, - ) - end - - final_update = messages.last.data - expect(final_update[:result]).to eq(proofread_result) - expect(final_update[:done]).to eq(true) - end - end - end - end -end diff --git a/spec/jobs/regular/stream_discord_reply_spec.rb b/spec/jobs/regular/stream_discord_reply_spec.rb deleted file mode 100644 index 7fb66ac5..00000000 --- a/spec/jobs/regular/stream_discord_reply_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe Jobs::StreamDiscordReply, type: :job do - let(:interaction) do - { - type: 2, - data: { - options: [{ value: "test query" }], - }, - token: "interaction_token", - }.to_json.to_s - end - - fab!(:llm_model) - fab!(:persona) { Fabricate(:ai_persona, default_llm_id: llm_model.id) } - - before do - enable_current_plugin - SiteSetting.ai_discord_search_enabled = true - SiteSetting.ai_discord_search_mode = "persona" - SiteSetting.ai_discord_search_persona = persona.id - end - - it "calls PersonaReplier when search mode is persona" do - expect_any_instance_of(DiscourseAi::Discord::Bot::PersonaReplier).to receive( - :handle_interaction!, - ) - described_class.new.execute(interaction: interaction) - end - - it "calls Search when search mode is not persona" do - SiteSetting.ai_discord_search_mode = "search" - expect_any_instance_of(DiscourseAi::Discord::Bot::Search).to receive(:handle_interaction!) - described_class.new.execute(interaction: interaction) - end -end diff --git a/spec/jobs/regular/stream_discover_reply_spec.rb b/spec/jobs/regular/stream_discover_reply_spec.rb deleted file mode 100644 index c1693c1d..00000000 --- a/spec/jobs/regular/stream_discover_reply_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::StreamDiscoverReply do - subject(:job) { described_class.new } - - before { enable_current_plugin } - - describe "#execute" do - fab!(:user) - fab!(:llm_model) - fab!(:group) - fab!(:ai_persona) do - Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: llm_model.id) - end - - before do - SiteSetting.ai_bot_discover_persona = ai_persona.id - group.add(user) - end - - def with_responses(responses) - DiscourseAi::Completions::Llm.with_prepared_responses(responses) { yield } - end - - it "publishes updates with a partial summary" do - with_responses(["dummy"]) do - messages = - MessageBus.track_publish("/discourse-ai/ai-bot/discover") do - job.execute(user_id: user.id, query: "Testing search") - end - - partial_update = messages.first.data - expect(partial_update[:done]).to eq(false) - expect(partial_update[:model_used]).to eq(llm_model.display_name) - expect(partial_update[:ai_discover_reply]).to eq("dummy") - end - end - - it "publishes a final update to signal we're done" do - with_responses(["dummy"]) do - messages = - MessageBus.track_publish("/discourse-ai/ai-bot/discover") do - job.execute(user_id: user.id, query: "Testing search") - end - - final_update = messages.last.data - expect(final_update[:done]).to eq(true) - - expect(final_update[:model_used]).to eq(llm_model.display_name) - expect(final_update[:ai_discover_reply]).to eq("dummy") - end - end - end -end diff --git a/spec/jobs/regular/stream_post_helper_spec.rb b/spec/jobs/regular/stream_post_helper_spec.rb deleted file mode 100644 index 80b84890..00000000 --- a/spec/jobs/regular/stream_post_helper_spec.rb +++ /dev/null @@ -1,164 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::StreamPostHelper do - subject(:job) { described_class.new } - - before do - enable_current_plugin - assign_fake_provider_to(:ai_helper_model) - end - - describe "#execute" do - fab!(:topic) - fab!(:post) do - Fabricate( - :post, - topic: topic, - raw: - "I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.", - ) - end - fab!(:user) { Fabricate(:leader) } - - before do - Group.find(Group::AUTO_GROUPS[:trust_level_3]).add(user) - SiteSetting.ai_helper_enabled = true - end - - describe "validates params" do - let(:mode) { DiscourseAi::AiHelper::Assistant::EXPLAIN } - - it "does nothing if there is no post" do - messages = - MessageBus.track_publish("/discourse-ai/ai-helper/streamed_suggestion/#{post.id}") do - job.execute(post_id: nil, user_id: user.id, text: "pie", prompt: mode) - end - - expect(messages).to be_empty - end - - it "does nothing if there is no user" do - messages = - MessageBus.track_publish("/discourse-ai/ai-helper/explain/#{post.id}") do - job.execute(post_id: post.id, user_id: nil, term_to_explain: "pie", prompt: mode) - end - - expect(messages).to be_empty - end - - it "does nothing if there is no text" do - messages = - MessageBus.track_publish("/discourse-ai/ai-helper/streamed_suggestion/#{post.id}") do - job.execute(post_id: post.id, user_id: user.id, text: nil, prompt: mode) - end - - expect(messages).to be_empty - end - end - - context "when the prompt is explain" do - let(:mode) { DiscourseAi::AiHelper::Assistant::EXPLAIN } - - it "publishes updates with a partial result" do - explanation = - "In this context, \"pie\" refers to a baked dessert typically consisting of a pastry crust and filling." - - channel = "/my/channel" - DiscourseAi::Completions::Llm.with_prepared_responses([explanation]) do - messages = - MessageBus.track_publish(channel) do - job.execute( - post_id: post.id, - user_id: user.id, - text: "pie", - prompt: mode, - progress_channel: channel, - client_id: "test_client_id", - ) - end - - partial_result_update = messages.first.data - expect(partial_result_update[:done]).to eq(false) - expect(partial_result_update[:result]).to eq(explanation) - end - end - - it "publishes a final update to signal we're done" do - explanation = - "In this context, \"pie\" refers to a baked dessert typically consisting of a pastry crust and filling." - - channel = "/my/channel" - - DiscourseAi::Completions::Llm.with_prepared_responses([explanation]) do - messages = - MessageBus.track_publish(channel) do - job.execute( - post_id: post.id, - user_id: user.id, - text: "pie", - prompt: mode, - client_id: "test_client_id", - progress_channel: channel, - ) - end - - final_update = messages.last.data - expect(final_update[:result]).to eq(explanation) - expect(final_update[:done]).to eq(true) - end - end - end - - context "when the prompt is translate" do - let(:mode) { DiscourseAi::AiHelper::Assistant::TRANSLATE } - - it "publishes updates with a partial result" do - sentence = "I like to eat pie." - translation = "Me gusta comer pastel." - - channel = "/my/channel" - DiscourseAi::Completions::Llm.with_prepared_responses([translation]) do - messages = - MessageBus.track_publish(channel) do - job.execute( - post_id: post.id, - user_id: user.id, - text: sentence, - prompt: mode, - progress_channel: channel, - client_id: "test_client_id", - ) - end - - partial_result_update = messages.first.data - expect(partial_result_update[:done]).to eq(false) - expect(partial_result_update[:result]).to eq(translation) - end - end - - it "publishes a final update to signal we're done" do - sentence = "I like to eat pie." - translation = "Me gusta comer pastel." - channel = "/my/channel" - - DiscourseAi::Completions::Llm.with_prepared_responses([translation]) do - messages = - MessageBus.track_publish(channel) do - job.execute( - post_id: post.id, - user_id: user.id, - text: sentence, - prompt: mode, - progress_channel: channel, - client_id: "test_client_id", - ) - end - - final_update = messages.last.data - expect(final_update[:result]).to eq(translation) - expect(final_update[:done]).to eq(true) - end - end - end - end -end diff --git a/spec/jobs/regular/stream_topic_ai_summary_spec.rb b/spec/jobs/regular/stream_topic_ai_summary_spec.rb deleted file mode 100644 index f290af29..00000000 --- a/spec/jobs/regular/stream_topic_ai_summary_spec.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::StreamTopicAiSummary do - subject(:job) { described_class.new } - - before { enable_current_plugin } - - describe "#execute" do - fab!(:topic) { Fabricate(:topic, highest_post_number: 2) } - fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) } - fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) } - fab!(:user) { Fabricate(:leader) } - - before do - Group.find(Group::AUTO_GROUPS[:trust_level_3]).add(user) - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - end - - def with_responses(responses) - DiscourseAi::Completions::Llm.with_prepared_responses(responses) { yield } - end - - describe "validates params" do - it "does nothing if there is no topic" do - messages = - MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do - job.execute(topic_id: nil, user_id: user.id) - end - - expect(messages).to be_empty - end - - it "does nothing if there is no user" do - messages = - MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do - job.execute(topic_id: topic.id, user_id: nil) - end - - expect(messages).to be_empty - end - - it "does nothing if the user is not allowed to see the topic" do - private_topic = Fabricate(:private_message_topic) - - messages = - MessageBus.track_publish("/discourse-ai/summaries/topic/#{private_topic.id}") do - job.execute(topic_id: private_topic.id, user_id: user.id) - end - - expect(messages).to be_empty - end - end - - it "publishes updates with a partial summary" do - summary = "dummy" - - with_responses([summary]) do - messages = - MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do - job.execute(topic_id: topic.id, user_id: user.id) - end - - partial_summary_update = messages.first.data - expect(partial_summary_update[:done]).to eq(false) - expect(partial_summary_update.dig(:ai_topic_summary, :summarized_text).chomp("\"}")).to eq( - summary, - ) - end - end - - it "publishes a final update to signal we're done and provide metadata" do - summary = "dummy" - - with_responses([summary]) do - messages = - MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do - job.execute(topic_id: topic.id, user_id: user.id) - end - - final_update = messages.last.data - expect(final_update[:done]).to eq(true) - - expect(final_update.dig(:ai_topic_summary, :summarized_text)).to eq(summary) - expect(final_update.dig(:ai_topic_summary, :algorithm)).to eq("fake") - expect(final_update.dig(:ai_topic_summary, :outdated)).to eq(false) - expect(final_update.dig(:ai_topic_summary, :can_regenerate)).to eq(true) - expect(final_update.dig(:ai_topic_summary, :new_posts_since_summary)).to be_zero - end - end - end -end diff --git a/spec/jobs/scheduled/categories_locale_detection_backfill_spec.rb b/spec/jobs/scheduled/categories_locale_detection_backfill_spec.rb deleted file mode 100644 index 6fc1f56a..00000000 --- a/spec/jobs/scheduled/categories_locale_detection_backfill_spec.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::CategoriesLocaleDetectionBackfill do - fab!(:category) { Fabricate(:category, locale: nil) } - subject(:job) { described_class.new } - - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.ai_translation_backfill_hourly_rate = 100 - SiteSetting.content_localization_supported_locales = "en" - end - - it "does nothing when AI is disabled" do - SiteSetting.discourse_ai_enabled = false - DiscourseAi::Translation::CategoryLocaleDetector.expects(:detect_locale).never - - job.execute({}) - end - - it "does nothing when content translation is disabled" do - SiteSetting.ai_translation_enabled = false - DiscourseAi::Translation::CategoryLocaleDetector.expects(:detect_locale).never - - job.execute({}) - end - - it "does nothing when backfill rate is 0" do - SiteSetting.ai_translation_backfill_hourly_rate = 0 - DiscourseAi::Translation::CategoryLocaleDetector.expects(:detect_locale).never - - job.execute({}) - end - - it "does nothing when there are no categories to detect" do - Category.update_all(locale: "en") - DiscourseAi::Translation::CategoryLocaleDetector.expects(:detect_locale).never - - job.execute({}) - end - - it "detects locale for categories with nil locale" do - DiscourseAi::Translation::CategoryLocaleDetector - .expects(:detect_locale) - .with(is_a(Category)) - .times(Category.count) - - job.execute({}) - end - - it "handles detection errors gracefully" do - DiscourseAi::Translation::CategoryLocaleDetector - .expects(:detect_locale) - .with(is_a(Category)) - .at_least_once - DiscourseAi::Translation::CategoryLocaleDetector - .expects(:detect_locale) - .with(category) - .raises(StandardError.new("error")) - .once - - expect { job.execute({}) }.not_to raise_error - end - - it "logs a summary after running" do - DiscourseAi::Translation::CategoryLocaleDetector.stubs(:detect_locale) - DiscourseAi::Translation::VerboseLogger.expects(:log).with( - includes("Detected #{Category.count} category locales"), - ) - - job.execute({}) - end - - describe "with public content limitation" do - fab!(:private_category) { Fabricate(:private_category, group: Group[:staff], locale: nil) } - - before do - # catch-all for other categories - DiscourseAi::Translation::CategoryLocaleDetector - .expects(:detect_locale) - .with(is_a(Category)) - .at_least_once - - SiteSetting.ai_translation_backfill_limit_to_public_content = true - end - - it "only processes public categories" do - DiscourseAi::Translation::CategoryLocaleDetector.expects(:detect_locale).with(category).once - DiscourseAi::Translation::CategoryLocaleDetector - .expects(:detect_locale) - .with(private_category) - .never - - job.execute({}) - end - - it "processes all categories when setting is disabled" do - SiteSetting.ai_translation_backfill_limit_to_public_content = false - - DiscourseAi::Translation::CategoryLocaleDetector.expects(:detect_locale).with(category).once - DiscourseAi::Translation::CategoryLocaleDetector - .expects(:detect_locale) - .with(private_category) - .once - - job.execute({}) - end - end - - it "limits processing to the backfill rate" do - SiteSetting.ai_translation_backfill_hourly_rate = 1 - Fabricate(:category, locale: nil) - - DiscourseAi::Translation::CategoryLocaleDetector.expects(:detect_locale).once - - job.execute({}) - end -end diff --git a/spec/jobs/scheduled/embeddings_backfill_spec.rb b/spec/jobs/scheduled/embeddings_backfill_spec.rb deleted file mode 100644 index 822da388..00000000 --- a/spec/jobs/scheduled/embeddings_backfill_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::EmbeddingsBackfill do - fab!(:second_topic) do - topic = Fabricate(:topic, created_at: 1.year.ago, bumped_at: 2.day.ago) - Fabricate(:post, topic: topic) - topic - end - - fab!(:first_topic) do - topic = Fabricate(:topic, created_at: 1.year.ago, bumped_at: 1.day.ago) - Fabricate(:post, topic: topic) - topic - end - - fab!(:third_topic) do - topic = Fabricate(:topic, created_at: 1.year.ago, bumped_at: 3.day.ago) - Fabricate(:post, topic: topic) - topic - end - - fab!(:vector_def) { Fabricate(:embedding_definition) } - fab!(:vector_def2) { Fabricate(:embedding_definition) } - fab!(:embedding_array) { Array.new(1024) { 1 } } - - before do - enable_current_plugin - - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_enabled = true - SiteSetting.ai_embeddings_backfill_batch_size = 1 - SiteSetting.ai_embeddings_per_post_enabled = true - Jobs.run_immediately! - - WebMock.stub_request(:post, "https://test.com/embeddings").to_return( - status: 200, - body: JSON.dump(embedding_array), - ) - end - - it "backfills topics based on bumped_at date" do - Jobs::EmbeddingsBackfill.new.execute({}) - - topic_ids = - DB.query_single("SELECT topic_id from #{DiscourseAi::Embeddings::Schema::TOPICS_TABLE}") - - expect(topic_ids).to eq([first_topic.id]) - - # pulse again for the rest (and cover code) - SiteSetting.ai_embeddings_backfill_batch_size = 100 - Jobs::EmbeddingsBackfill.new.execute({}) - - topic_ids = - DB.query_single("SELECT topic_id from #{DiscourseAi::Embeddings::Schema::TOPICS_TABLE}") - - expect(topic_ids).to contain_exactly(first_topic.id, second_topic.id, third_topic.id) - - freeze_time 1.day.from_now - - # new title forces a reindex - third_topic.update!(updated_at: Time.zone.now, title: "new title - 123") - - Jobs::EmbeddingsBackfill.new.execute({}) - - index_date = - DB.query_single( - "SELECT updated_at from #{DiscourseAi::Embeddings::Schema::TOPICS_TABLE} WHERE topic_id = ?", - third_topic.id, - ).first - - expect(index_date).to be_within_one_second_of(Time.zone.now) - end - - it "backfills embeddings for the ai_embeddings_backfill_model" do - SiteSetting.ai_embeddings_backfill_model = vector_def2.id - SiteSetting.ai_embeddings_backfill_batch_size = 100 - - Jobs::EmbeddingsBackfill.new.execute({}) - - topic_ids = - DB.query_single( - "SELECT topic_id from #{DiscourseAi::Embeddings::Schema::TOPICS_TABLE} WHERE model_id = ?", - vector_def2.id, - ) - - expect(topic_ids).to contain_exactly(first_topic.id, second_topic.id, third_topic.id) - end -end diff --git a/spec/jobs/scheduled/generate_concepts_from_popular_items_spec.rb b/spec/jobs/scheduled/generate_concepts_from_popular_items_spec.rb deleted file mode 100644 index ae0b6fd7..00000000 --- a/spec/jobs/scheduled/generate_concepts_from_popular_items_spec.rb +++ /dev/null @@ -1,260 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::GenerateConceptsFromPopularItems do - fab!(:topic) { Fabricate(:topic, posts_count: 6, views: 150, like_count: 12) } - fab!(:post) { Fabricate(:post, like_count: 8, post_number: 2) } - - before do - enable_current_plugin - SiteSetting.inferred_concepts_enabled = true - SiteSetting.inferred_concepts_daily_topics_limit = 20 - SiteSetting.inferred_concepts_daily_posts_limit = 30 - SiteSetting.inferred_concepts_min_posts = 5 - SiteSetting.inferred_concepts_min_likes = 10 - SiteSetting.inferred_concepts_min_views = 100 - SiteSetting.inferred_concepts_post_min_likes = 5 - SiteSetting.inferred_concepts_lookback_days = 30 - SiteSetting.inferred_concepts_background_match = false - end - - describe "#execute" do - it "does nothing when inferred_concepts_enabled is false" do - SiteSetting.inferred_concepts_enabled = false - - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).not_to receive( - :find_candidate_topics, - ) - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).not_to receive( - :find_candidate_posts, - ) - allow(Jobs).to receive(:enqueue) - - subject.execute({}) - end - - it "processes popular topics when enabled" do - candidate_topics = [topic] - - freeze_time do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_topics, - ).with( - limit: 20, - min_posts: 5, - min_likes: 10, - min_views: 100, - created_after: 30.days.ago, - ).and_return(candidate_topics) - - allow(Jobs).to receive(:enqueue).with( - :generate_inferred_concepts, - item_type: "topics", - item_ids: [topic.id], - batch_size: 10, - ) - - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_posts, - ).and_return([]) - - subject.execute({}) - end - end - - it "processes popular posts when enabled" do - candidate_posts = [post] - - freeze_time do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_topics, - ).and_return([]) - - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_posts, - ).with( - limit: 30, - min_likes: 5, - exclude_first_posts: true, - created_after: 30.days.ago, - ).and_return(candidate_posts) - - allow(Jobs).to receive(:enqueue).with( - :generate_inferred_concepts, - item_type: "posts", - item_ids: [post.id], - batch_size: 10, - ) - - subject.execute({}) - end - end - - it "schedules background matching jobs when enabled" do - SiteSetting.inferred_concepts_background_match = true - - candidate_topics = [topic] - candidate_posts = [post] - - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_topics, - ).and_return(candidate_topics) - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_posts, - ).and_return(candidate_posts) - - # Expect generation jobs - allow(Jobs).to receive(:enqueue).with( - :generate_inferred_concepts, - item_type: "topics", - item_ids: [topic.id], - batch_size: 10, - ) - - allow(Jobs).to receive(:enqueue).with( - :generate_inferred_concepts, - item_type: "posts", - item_ids: [post.id], - batch_size: 10, - ) - - # Expect background matching jobs - allow(Jobs).to receive(:enqueue_in).with( - 1.hour, - :generate_inferred_concepts, - item_type: "topics", - item_ids: [topic.id], - batch_size: 10, - match_only: true, - ) - - allow(Jobs).to receive(:enqueue_in).with( - 1.hour, - :generate_inferred_concepts, - item_type: "posts", - item_ids: [post.id], - batch_size: 10, - match_only: true, - ) - - subject.execute({}) - end - - it "does not schedule jobs when no candidates found" do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_topics, - ).and_return([]) - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_posts, - ).and_return([]) - - allow(Jobs).to receive(:enqueue) - allow(Jobs).to receive(:enqueue_in) - - subject.execute({}) - end - - it "uses site setting values for topic filtering" do - SiteSetting.inferred_concepts_daily_topics_limit = 50 - SiteSetting.inferred_concepts_min_posts = 8 - SiteSetting.inferred_concepts_min_likes = 15 - SiteSetting.inferred_concepts_min_views = 200 - SiteSetting.inferred_concepts_lookback_days = 45 - - freeze_time do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_topics, - ).with( - limit: 50, - min_posts: 8, - min_likes: 15, - min_views: 200, - created_after: 45.days.ago, - ).and_return([]) - - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_posts, - ).and_return([]) - - subject.execute({}) - end - end - - it "uses site setting values for post filtering" do - SiteSetting.inferred_concepts_daily_posts_limit = 40 - SiteSetting.inferred_concepts_post_min_likes = 8 - SiteSetting.inferred_concepts_lookback_days = 45 - - freeze_time do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_topics, - ).and_return([]) - - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_posts, - ).with( - limit: 40, - min_likes: 8, - exclude_first_posts: true, - created_after: 45.days.ago, - ).and_return([]) - - subject.execute({}) - end - end - - it "handles nil site setting values gracefully" do - SiteSetting.inferred_concepts_daily_topics_limit = nil - SiteSetting.inferred_concepts_daily_posts_limit = nil - SiteSetting.inferred_concepts_min_posts = nil - SiteSetting.inferred_concepts_min_likes = nil - SiteSetting.inferred_concepts_min_views = nil - SiteSetting.inferred_concepts_post_min_likes = nil - # Keep lookback_days at default so .days.ago doesn't fail - - freeze_time do - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_topics, - ).with( - limit: 0, # nil becomes 0 - min_posts: 0, # nil becomes 0 - min_likes: 0, # nil becomes 0 - min_views: 0, # nil becomes 0 - created_after: 30.days.ago, # default from before block - ).and_return([]) - - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_posts, - ).with( - limit: 0, # nil becomes 0 - min_likes: 0, # nil becomes 0 - exclude_first_posts: true, - created_after: 30.days.ago, # default from before block - ).and_return([]) - - subject.execute({}) - end - end - - it "processes both topics and posts in the same run" do - candidate_topics = [topic] - candidate_posts = [post] - - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_topics, - ).and_return(candidate_topics) - expect_any_instance_of(DiscourseAi::InferredConcepts::Manager).to receive( - :find_candidate_posts, - ).and_return(candidate_posts) - - allow(Jobs).to receive(:enqueue).twice - - subject.execute({}) - end - end - - context "when scheduling the job" do - it "is scheduled to run daily" do - expect(described_class.every).to eq(1.day) - end - end -end diff --git a/spec/jobs/scheduled/post_localization_backfill_spec.rb b/spec/jobs/scheduled/post_localization_backfill_spec.rb deleted file mode 100644 index 7a21cc1f..00000000 --- a/spec/jobs/scheduled/post_localization_backfill_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::PostLocalizationBackfill do - before do - enable_current_plugin - SiteSetting.ai_translation_backfill_hourly_rate = 100 - SiteSetting.content_localization_supported_locales = "en" - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - end - - it "does not enqueue post translation when translator disabled" do - SiteSetting.discourse_ai_enabled = false - - described_class.new.execute({}) - - expect_not_enqueued_with(job: :localize_posts) - end - - it "does not enqueue post translation when experimental translation disabled" do - SiteSetting.ai_translation_enabled = false - - described_class.new.execute({}) - - expect_not_enqueued_with(job: :localize_posts) - end - - it "does not enqueue post translation if backfill languages are not set" do - SiteSetting.content_localization_supported_locales = "" - - described_class.new.execute({}) - - expect_not_enqueued_with(job: :localize_posts) - end - - it "does not enqueue post translation if backfill limit is set to 0" do - SiteSetting.ai_translation_enabled = true - SiteSetting.ai_translation_backfill_hourly_rate = 0 - - described_class.new.execute({}) - - expect_not_enqueued_with(job: :localize_posts) - end - - it "enqueues post translation with correct limit" do - SiteSetting.ai_translation_enabled = true - SiteSetting.ai_translation_backfill_hourly_rate = 100 - - described_class.new.execute({}) - - expect_job_enqueued(job: :localize_posts, args: { limit: 8 }) - end -end diff --git a/spec/jobs/scheduled/posts_locale_detection_backfill_spec.rb b/spec/jobs/scheduled/posts_locale_detection_backfill_spec.rb deleted file mode 100644 index 926fa461..00000000 --- a/spec/jobs/scheduled/posts_locale_detection_backfill_spec.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::PostsLocaleDetectionBackfill do - fab!(:post) { Fabricate(:post, locale: nil) } - subject(:job) { described_class.new } - - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.ai_translation_backfill_hourly_rate = 100 - SiteSetting.content_localization_supported_locales = "en" - end - - it "does nothing when translator is disabled" do - SiteSetting.discourse_ai_enabled = false - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).never - - job.execute({}) - end - - it "does nothing when content translation is disabled" do - SiteSetting.ai_translation_enabled = false - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).never - - job.execute({}) - end - - it "does nothing when there are no posts to detect" do - Post.update_all(locale: "en") - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).never - - job.execute({}) - end - - it "detects locale for posts with nil locale" do - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(post).once - job.execute({}) - end - - it "detects most recently updated posts first" do - post_2 = Fabricate(:post, locale: nil) - post_3 = Fabricate(:post, locale: nil) - - post.update!(updated_at: 3.days.ago) - post_2.update!(updated_at: 2.day.ago) - post_3.update!(updated_at: 4.day.ago) - - SiteSetting.ai_translation_backfill_hourly_rate = 12 - - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(post_2).once - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(post).never - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(post_3).never - - job.execute({}) - end - - it "skips bot posts" do - post.update!(user: Discourse.system_user) - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(post).never - - job.execute({}) - end - - it "handles detection errors gracefully" do - DiscourseAi::Translation::PostLocaleDetector - .expects(:detect_locale) - .with(post) - .raises(StandardError.new("jiboomz")) - .once - - expect { job.execute({}) }.not_to raise_error - end - - it "logs a summary after running" do - DiscourseAi::Translation::PostLocaleDetector.stubs(:detect_locale) - DiscourseAi::Translation::VerboseLogger.expects(:log).with(includes("Detected 1 post locales")) - - job.execute({}) - end - - describe "with public content limitation" do - fab!(:private_category) { Fabricate(:private_category, group: Group[:staff]) } - fab!(:private_cat_topic) { Fabricate(:topic, category: private_category) } - fab!(:private_cat_post) { Fabricate(:post, topic: private_cat_topic, locale: nil) } - - fab!(:group) - fab!(:group_pm_topic) { Fabricate(:private_message_topic, allowed_groups: [group]) } - fab!(:group_pm_post) { Fabricate(:post, topic: group_pm_topic, locale: nil) } - - fab!(:pm_topic) { Fabricate(:private_message_topic) } - fab!(:pm_post) { Fabricate(:post, topic: pm_topic, locale: nil) } - - before { SiteSetting.ai_translation_backfill_limit_to_public_content = true } - - it "only processes posts from public categories" do - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(post).once - DiscourseAi::Translation::PostLocaleDetector - .expects(:detect_locale) - .with(private_cat_post) - .never - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(group_pm_post).never - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(pm_post).never - - job.execute({}) - end - - it "processes all public content and group PMs and private categories when setting is disabled" do - SiteSetting.ai_translation_backfill_limit_to_public_content = false - - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(post).once - DiscourseAi::Translation::PostLocaleDetector - .expects(:detect_locale) - .with(private_cat_post) - .once - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(group_pm_post).once - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(pm_post).never - - job.execute({}) - end - end - - describe "with max age limit" do - fab!(:old_post) { Fabricate(:post, locale: nil, created_at: 10.days.ago) } - fab!(:new_post) { Fabricate(:post, locale: nil, created_at: 2.days.ago) } - - before { SiteSetting.ai_translation_backfill_max_age_days = 5 } - - it "only processes posts within the age limit" do - # other posts - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).at_least_once - - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(new_post).once - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(old_post).never - - job.execute({}) - end - - it "processes all posts when setting is large" do - SiteSetting.ai_translation_backfill_max_age_days = 100 - - # other posts - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).at_least_once - - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(new_post).once - DiscourseAi::Translation::PostLocaleDetector.expects(:detect_locale).with(old_post).once - - job.execute({}) - end - end -end diff --git a/spec/jobs/scheduled/remove_orphaned_embeddings_spec.rb b/spec/jobs/scheduled/remove_orphaned_embeddings_spec.rb deleted file mode 100644 index 3cccca10..00000000 --- a/spec/jobs/scheduled/remove_orphaned_embeddings_spec.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::RemoveOrphanedEmbeddings do - before { enable_current_plugin } - - describe "#execute" do - fab!(:embedding_definition) - fab!(:embedding_definition_2) { Fabricate(:embedding_definition) } - fab!(:topic) - fab!(:post) - - before do - DiscourseAi::Embeddings::Schema.prepare_search_indexes(embedding_definition) - DiscourseAi::Embeddings::Schema.prepare_search_indexes(embedding_definition_2) - - # Seed embeddings. One of each def x target classes. - [embedding_definition, embedding_definition_2].each do |edef| - SiteSetting.ai_embeddings_selected_model = edef.id - - [topic, post].each do |target| - schema = DiscourseAi::Embeddings::Schema.for(target.class) - schema.store(target, [1] * edef.dimensions, "test") - end - end - - embedding_definition.destroy! - end - - def find_all_embeddings_of(target, table, target_column) - DB.query_single("SELECT model_id FROM #{table} WHERE #{target_column} = #{target.id}") - end - - it "delete embeddings without an existing embedding definition" do - expect(find_all_embeddings_of(post, "ai_posts_embeddings", "post_id")).to contain_exactly( - embedding_definition.id, - embedding_definition_2.id, - ) - expect(find_all_embeddings_of(topic, "ai_topics_embeddings", "topic_id")).to contain_exactly( - embedding_definition.id, - embedding_definition_2.id, - ) - - subject.execute({}) - - expect(find_all_embeddings_of(topic, "ai_topics_embeddings", "topic_id")).to contain_exactly( - embedding_definition_2.id, - ) - expect(find_all_embeddings_of(post, "ai_posts_embeddings", "post_id")).to contain_exactly( - embedding_definition_2.id, - ) - end - - it "deletes orphaned indexes" do - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_definition)).to eq(true) - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_definition_2)).to eq(true) - - subject.execute({}) - - index_names = - DiscourseAi::Embeddings::Schema::EMBEDDING_TARGETS.map do |t| - "ai_#{t}_embeddings_#{embedding_definition.id}_1_search_bit" - end - indexnames = - DB.query_single( - "SELECT indexname FROM pg_indexes WHERE indexname IN (:names)", - names: index_names, - ) - - expect(indexnames).to be_empty - expect(DiscourseAi::Embeddings::Schema.correctly_indexed?(embedding_definition_2)).to eq(true) - end - end -end diff --git a/spec/jobs/scheduled/sentiment_backfill_spec.rb b/spec/jobs/scheduled/sentiment_backfill_spec.rb deleted file mode 100644 index 878b3425..00000000 --- a/spec/jobs/scheduled/sentiment_backfill_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require_relative "../../support/sentiment_inference_stubs" - -RSpec.describe Jobs::SentimentBackfill do - before { enable_current_plugin } - - describe "#execute" do - fab!(:post) - - before do - SiteSetting.ai_sentiment_enabled = true - SiteSetting.ai_sentiment_backfill_maximum_posts_per_hour = 100 - SiteSetting.ai_sentiment_model_configs = - "[{\"model_name\":\"SamLowe/roberta-base-go_emotions\",\"endpoint\":\"http://samlowe-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"j-hartmann/emotion-english-distilroberta-base\",\"endpoint\":\"http://jhartmann-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"cardiffnlp/twitter-roberta-base-sentiment-latest\",\"endpoint\":\"http://cardiffnlp-sentiment.com\",\"api_key\":\"123\"}]" - end - - let(:expected_analysis) { DiscourseAi::Sentiment::SentimentSiteSettingJsonSchema.values.length } - - it "backfills when settings are correct" do - SentimentInferenceStubs.stub_classification(post) - subject.execute({}) - - expect(ClassificationResult.where(target: post).count).to eq(expected_analysis) - end - - it "does nothing when batch size is zero" do - SiteSetting.ai_sentiment_backfill_maximum_posts_per_hour = 0 - - subject.execute({}) - - expect(ClassificationResult.count).to be_zero - end - - it "does nothing when sentiment is disabled" do - SiteSetting.ai_sentiment_enabled = false - - subject.execute({}) - - expect(ClassificationResult.count).to be_zero - end - - it "respects the ai_sentiment_backfill_post_max_age_days setting" do - SentimentInferenceStubs.stub_classification(post) - SiteSetting.ai_sentiment_backfill_post_max_age_days = 80 - post_2 = Fabricate(:post, created_at: 81.days.ago) - - subject.execute({}) - - expect(ClassificationResult.where(target: post).count).to eq(expected_analysis) - expect(ClassificationResult.where(target: post_2).count).to be_zero - end - end -end diff --git a/spec/jobs/scheduled/summaries_backfill_spec.rb b/spec/jobs/scheduled/summaries_backfill_spec.rb deleted file mode 100644 index c3bdf415..00000000 --- a/spec/jobs/scheduled/summaries_backfill_spec.rb +++ /dev/null @@ -1,134 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::SummariesBackfill do - fab!(:topic) do - Fabricate(:topic, word_count: 200, highest_post_number: 2, last_posted_at: 2.hours.ago) - end - let(:limit) { 24 } # guarantee two summaries per batch - let(:intervals) { 12 } # budget is split into intervals. Job runs every five minutes. - - before do - enable_current_plugin - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - SiteSetting.ai_summary_backfill_maximum_topics_per_hour = limit - SiteSetting.ai_summary_gists_enabled = true - end - - describe "#current_budget" do - let(:type) { AiSummary.summary_types[:complete] } - - context "when no summary has been backfilled yet" do - it "returns the full budget" do - expect(subject.current_budget(type)).to eq(limit / intervals) - end - - it "ignores summaries generated by users" do - Fabricate(:ai_summary, target: topic, origin: AiSummary.origins[:human]) - - expect(subject.current_budget(type)).to eq(limit / intervals) - end - - it "only accounts for summaries of the given type" do - Fabricate(:topic_ai_gist, target: topic, origin: AiSummary.origins[:human]) - - expect(subject.current_budget(type)).to eq(limit / intervals) - end - end - end - - describe "#backfill_candidates" do - let(:type) { AiSummary.summary_types[:complete] } - - it "only selects posts with enough words" do - topic.update!(word_count: 100) - - expect(subject.backfill_candidates(type)).to be_empty - end - - it "ignores up to date summaries" do - Fabricate(:ai_summary, target: topic, highest_target_number: 2, updated_at: 10.minutes.ago) - - expect(subject.backfill_candidates(type)).to be_empty - end - - it "ignores outdated summaries updated less than five minutes ago" do - Fabricate(:ai_summary, target: topic, highest_target_number: 1, updated_at: 4.minutes.ago) - - expect(subject.backfill_candidates(type)).to be_empty - end - - it "orders candidates by topic#last_posted_at" do - topic.update!(last_posted_at: 1.minute.ago) - topic_2 = Fabricate(:topic, word_count: 200, last_posted_at: 2.minutes.ago) - - expect(subject.backfill_candidates(type).map(&:id)).to contain_exactly(topic.id, topic_2.id) - end - - it "prioritizes topics without summaries" do - topic_2 = - Fabricate(:topic, word_count: 200, last_posted_at: 2.minutes.ago, highest_post_number: 1) - topic.update!(last_posted_at: 1.minute.ago) - Fabricate(:ai_summary, target: topic, updated_at: 1.hour.ago, highest_target_number: 1) - - expect(subject.backfill_candidates(type).map(&:id)).to contain_exactly(topic_2.id, topic.id) - end - - it "respects max age setting" do - SiteSetting.ai_summary_backfill_topic_max_age_days = 1 - topic.update!(last_posted_at: 2.days.ago) - - expect(subject.backfill_candidates(type)).to be_empty - end - end - - describe "#execute" do - it "backfills a batch" do - topic_2 = - Fabricate(:topic, word_count: 200, last_posted_at: 2.minutes.ago, highest_post_number: 1) - topic.update!(last_posted_at: 1.minute.ago) - Fabricate(:ai_summary, target: topic, updated_at: 3.hours.ago, highest_target_number: 1) - Fabricate(:topic_ai_gist, target: topic, updated_at: 3.hours.ago, highest_target_number: 1) - - summary_1 = "Summary of topic_2" - gist_1 = "Gist of topic_2" - summary_2 = "Updated summary of topic" - gist_2 = "Updated gist of topic" - - DiscourseAi::Completions::Llm.with_prepared_responses( - [gist_1, gist_2, summary_1, summary_2], - ) { subject.execute({}) } - - expect(AiSummary.complete.find_by(target: topic_2).summarized_text).to eq(summary_1) - expect(AiSummary.gist.find_by(target: topic_2).summarized_text).to eq(gist_1) - expect(AiSummary.complete.find_by(target: topic).summarized_text).to eq(summary_2) - expect(AiSummary.gist.find_by(target: topic).summarized_text).to eq(gist_2) - - # Queue has to be empty if we just generated all summaries - expect(subject.backfill_candidates(AiSummary.summary_types[:complete])).to be_empty - expect(subject.backfill_candidates(AiSummary.summary_types[:gist])).to be_empty - - # Queue still empty when they are up to date and time passes. - AiSummary.update_all(updated_at: 20.minutes.ago) - expect(subject.backfill_candidates(AiSummary.summary_types[:complete])).to be_empty - expect(subject.backfill_candidates(AiSummary.summary_types[:gist])).to be_empty - end - - it "updates the highest_target_number if the summary turned to be up to date" do - og_highest_post_number = topic.highest_post_number - existing_summary = - Fabricate( - :ai_summary, - target: topic, - updated_at: 3.hours.ago, - highest_target_number: og_highest_post_number, - ) - topic.update!(highest_post_number: og_highest_post_number + 1) - - # No prepared responses here. We don't perform a completion call. - subject.execute({}) - - expect(existing_summary.reload.highest_target_number).to eq(og_highest_post_number + 1) - end - end -end diff --git a/spec/jobs/scheduled/topics_locale_detection_backfill_spec.rb b/spec/jobs/scheduled/topics_locale_detection_backfill_spec.rb deleted file mode 100644 index 88f27217..00000000 --- a/spec/jobs/scheduled/topics_locale_detection_backfill_spec.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -describe Jobs::TopicsLocaleDetectionBackfill do - fab!(:topic) { Fabricate(:topic, locale: nil) } - subject(:job) { described_class.new } - - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.ai_translation_backfill_hourly_rate = 100 - SiteSetting.content_localization_supported_locales = "en" - end - - it "does nothing when translator is disabled" do - SiteSetting.discourse_ai_enabled = false - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).never - - job.execute({ limit: 10 }) - end - - it "does nothing when content translation is disabled" do - SiteSetting.ai_translation_enabled = false - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).never - - job.execute({ limit: 10 }) - end - - it "does nothing when there are no topics to detect" do - Topic.update_all(locale: "en") - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).never - - job.execute({ limit: 10 }) - end - - it "detects locale for topics with nil locale" do - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(topic).once - job.execute({ limit: 10 }) - end - - it "detects most recently updated topics first" do - topic_2 = Fabricate(:topic, locale: nil) - topic_3 = Fabricate(:topic, locale: nil) - - topic.update!(updated_at: 3.days.ago) - topic_2.update!(updated_at: 2.day.ago) - topic_3.update!(updated_at: 4.day.ago) - - SiteSetting.ai_translation_backfill_hourly_rate = 12 - - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(topic_2).once - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(topic).never - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(topic_3).never - - job.execute({ limit: 10 }) - end - - it "skips bot topics" do - topic.update!(user: Discourse.system_user) - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(topic).never - - job.execute({ limit: 10 }) - end - - it "handles detection errors gracefully" do - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(topic) - .raises(StandardError.new("jiboomz")) - .once - - expect { job.execute({ limit: 10 }) }.not_to raise_error - end - - it "logs a summary after running" do - DiscourseAi::Translation::TopicLocaleDetector.stubs(:detect_locale) - DiscourseAi::Translation::VerboseLogger.expects(:log).with(includes("Detected 1 topic locales")) - - job.execute({ limit: 10 }) - end - - describe "with public content limitation" do - fab!(:private_category) { Fabricate(:private_category, group: Group[:staff]) } - fab!(:private_cat_topic) { Fabricate(:topic, category: private_category, locale: nil) } - - fab!(:group) - fab!(:group_pm_topic) { Fabricate(:private_message_topic, allowed_groups: [group]) } - - fab!(:pm_topic) { Fabricate(:private_message_topic) } - - fab!(:public_topic) { Fabricate(:topic, locale: nil) } - - before do - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).at_least_once - - SiteSetting.ai_translation_backfill_limit_to_public_content = true - end - - it "only processes topics from public categories" do - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(public_topic).once - - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(private_cat_topic) - .never - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(group_pm_topic) - .never - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(pm_topic).never - - job.execute({ limit: 10 }) - end - - it "processes public category topics, group PMs, and private category topics when setting is disabled" do - SiteSetting.ai_translation_backfill_limit_to_public_content = false - - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(public_topic).once - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(group_pm_topic) - .once - DiscourseAi::Translation::TopicLocaleDetector - .expects(:detect_locale) - .with(private_cat_topic) - .once - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(pm_topic).never - - job.execute({ limit: 10 }) - end - end - - describe "with max age limit" do - fab!(:old_topic) { Fabricate(:topic, locale: nil, created_at: 10.days.ago) } - fab!(:new_topic) { Fabricate(:topic, locale: nil, created_at: 2.days.ago) } - - before do - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).at_least_once - - SiteSetting.ai_translation_backfill_max_age_days = 5 - end - - it "only processes topics within the age limit" do - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(new_topic).once - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(old_topic).never - - job.execute({ limit: 10 }) - end - - it "processes all topics when setting is large" do - SiteSetting.ai_translation_backfill_max_age_days = 100 - - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(new_topic).once - DiscourseAi::Translation::TopicLocaleDetector.expects(:detect_locale).with(old_topic).once - - job.execute({ limit: 10 }) - end - end -end diff --git a/spec/jobs/shared_conversation_adjust_upload_security_spec.rb b/spec/jobs/shared_conversation_adjust_upload_security_spec.rb deleted file mode 100644 index 377ffe95..00000000 --- a/spec/jobs/shared_conversation_adjust_upload_security_spec.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::SharedConversationAdjustUploadSecurity do - let(:params) { {} } - - fab!(:claude_2) { Fabricate(:llm_model, name: "claude-2") } - - fab!(:bot_user) do - enable_current_plugin - toggle_enabled_bots(bots: [claude_2]) - SiteSetting.ai_bot_enabled = true - SiteSetting.ai_bot_allowed_groups = "10" - SiteSetting.ai_bot_public_sharing_allowed_groups = "10" - claude_2.reload.user - end - - fab!(:user) - fab!(:topic) { Fabricate(:private_message_topic, user: user, recipient: bot_user) } - fab!(:post_1) { Fabricate(:post, topic: topic, user: bot_user) } - fab!(:post_2) { Fabricate(:post, topic: topic, user: user) } - fab!(:conversation) { SharedAiConversation.share_conversation(user, topic) } - - def run_job - described_class.new.execute(params) - end - - before { enable_current_plugin } - - context "when conversation is created" do - let(:params) { { conversation_id: conversation.id } } - - it "does nothing for a conversation that has been deleted before the job ran" do - conversation.destroy - SharedAiConversation.any_instance.expects(:update).never - run_job - end - - it "does nothing if there weren't any posts with secure uploads in the topic" do - original_context = conversation.context - run_job - expect(conversation.reload.context).to eq(original_context) - end - - context "when topic posts were rebaked because they had secure uploads" do - it "updates the conversation cooked post content after rebaking" do - post_2.update!(raw: "some new rebaked content") - TopicUploadSecurityManager.any_instance.expects(:run).returns([post_2]) - original_context = conversation.context - run_job - expect(conversation.reload.context).not_to eq(original_context) - end - end - end - - context "when conversation has been deleted" do - let(:params) { { target_id: topic.id, target_type: "Topic" } } - - before { conversation.destroy! } - - it "runs the topic upload security manager but doesn't attempt to update a conversation" do - SharedAiConversation.any_instance.expects(:update).never - TopicUploadSecurityManager.any_instance.expects(:run).once - run_job - end - - it "doesn't attempt to run the topic upload security manager if the topic has been deleted" do - TopicUploadSecurityManager.any_instance.expects(:run).never - topic.trash! - run_job - end - end -end diff --git a/spec/lib/completions/anthropic_message_processor_spec.rb b/spec/lib/completions/anthropic_message_processor_spec.rb deleted file mode 100644 index f6594d8d..00000000 --- a/spec/lib/completions/anthropic_message_processor_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Completions::AnthropicMessageProcessor do - before { enable_current_plugin } - - it "correctly handles and combines partial thinking chunks into complete thinking objects" do - processor = - DiscourseAi::Completions::AnthropicMessageProcessor.new( - streaming_mode: true, - partial_tool_calls: false, - output_thinking: true, - ) - - # Simulate streaming thinking output in multiple chunks - result1 = - processor.process_streamed_message( - { type: "content_block_start", content_block: { type: "thinking", thinking: "" } }, - ) - - result2 = - processor.process_streamed_message( - { - type: "content_block_delta", - delta: { - type: "thinking_delta", - thinking: "First part of thinking", - }, - }, - ) - - result3 = - processor.process_streamed_message( - { - type: "content_block_delta", - delta: { - type: "thinking_delta", - thinking: " and second part", - }, - }, - ) - - _result4 = - processor.process_streamed_message( - { - type: "content_block_delta", - delta: { - type: "signature_delta", - signature: "thinking-sig-123", - }, - }, - ) - - # Finish the thinking block - final_result = processor.process_streamed_message({ type: "content_block_stop" }) - - # Verify the partial thinking chunks - expect(result1).to be_a(DiscourseAi::Completions::Thinking) - expect(result1.message).to eq("") - expect(result1.partial?).to eq(true) - - expect(result2).to be_a(DiscourseAi::Completions::Thinking) - expect(result2.message).to eq("First part of thinking") - expect(result2.partial?).to eq(true) - - expect(result3).to be_a(DiscourseAi::Completions::Thinking) - expect(result3.message).to eq(" and second part") - expect(result3.partial?).to eq(true) - - # Verify the final complete thinking object - expect(final_result).to be_a(DiscourseAi::Completions::Thinking) - expect(final_result.message).to eq("First part of thinking and second part") - expect(final_result.signature).to eq("thinking-sig-123") - expect(final_result.partial?).to eq(false) - end -end diff --git a/spec/lib/completions/cancel_manager_spec.rb b/spec/lib/completions/cancel_manager_spec.rb deleted file mode 100644 index a1f53183..00000000 --- a/spec/lib/completions/cancel_manager_spec.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Completions::CancelManager do - fab!(:model) { Fabricate(:anthropic_model, name: "test-model") } - - before do - enable_current_plugin - WebMock.allow_net_connect! - end - - it "can stop monitoring for cancellation cleanly" do - cancel_manager = DiscourseAi::Completions::CancelManager.new - cancel_manager.start_monitor(delay: 100) { false } - expect(cancel_manager.monitor_thread).not_to be_nil - cancel_manager.stop_monitor - expect(cancel_manager.cancelled?).to eq(false) - expect(cancel_manager.monitor_thread).to be_nil - end - - it "can monitor for cancellation" do - cancel_manager = DiscourseAi::Completions::CancelManager.new - results = [true, false, false] - - cancel_manager.start_monitor(delay: 0) { results.pop } - - wait_for { cancel_manager.cancelled? == true } - wait_for { cancel_manager.monitor_thread.nil? } - - expect(cancel_manager.cancelled?).to eq(true) - expect(cancel_manager.monitor_thread).to be_nil - end - - it "should do nothing when cancel manager is already cancelled" do - cancel_manager = DiscourseAi::Completions::CancelManager.new - cancel_manager.cancel! - - llm = model.to_llm - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a test bot", - messages: [{ type: :user, content: "hello" }], - ) - - result = llm.generate(prompt, user: Discourse.system_user, cancel_manager: cancel_manager) - expect(result).to be_nil - end - - it "should be able to cancel a completion" do - # Start an HTTP server that hangs indefinitely - server = TCPServer.new("127.0.0.1", 0) - port = server.addr[1] - - begin - thread = - Thread.new do - loop do - begin - _client = server.accept - sleep(30) # Hold the connection longer than the test will run - break - rescue StandardError - # Server closed - break - end - end - end - - # Create a model that points to our hanging server - model.update!(url: "http://127.0.0.1:#{port}") - - cancel_manager = DiscourseAi::Completions::CancelManager.new - - completion_thread = - Thread.new do - llm = model.to_llm - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a test bot", - messages: [{ type: :user, content: "hello" }], - ) - - result = llm.generate(prompt, user: Discourse.system_user, cancel_manager: cancel_manager) - expect(result).to be_nil - expect(cancel_manager.cancelled).to eq(true) - end - - wait_for { cancel_manager.callbacks.size == 1 } - - cancel_manager.cancel! - completion_thread.join(2) - - expect(completion_thread).not_to be_alive - ensure - begin - server.close - rescue StandardError - nil - end - begin - thread.kill - rescue StandardError - nil - end - begin - completion_thread&.kill - rescue StandardError - nil - end - end - end -end diff --git a/spec/lib/completions/dialects/chat_gpt_spec.rb b/spec/lib/completions/dialects/chat_gpt_spec.rb deleted file mode 100644 index 981369c5..00000000 --- a/spec/lib/completions/dialects/chat_gpt_spec.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -require_relative "dialect_context" - -RSpec.describe DiscourseAi::Completions::Dialects::ChatGpt do - fab!(:llm_model) { Fabricate(:llm_model, max_prompt_tokens: 8192) } - let(:context) { DialectContext.new(described_class, llm_model) } - - before { enable_current_plugin } - - describe "#translate" do - it "translates a prompt written in our generic format to the ChatGPT format" do - open_ai_version = [ - { role: "system", content: context.system_insts }, - { role: "user", content: context.simple_user_input }, - ] - - translated = context.system_user_scenario - - expect(translated).to contain_exactly(*open_ai_version) - end - - it "will retain usernames for unicode usernames, correctly in mixed mode" do - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot", - messages: [ - { id: "👻", type: :user, content: "Message1" }, - { type: :model, content: "Ok" }, - { id: "joe", type: :user, content: "Message2" }, - ], - ) - - translated = context.dialect(prompt).translate - - expect(translated).to eq( - [ - { role: "system", content: "You are a bot" }, - { role: "user", content: "👻: Message1" }, - { role: "assistant", content: "Ok" }, - { role: "user", content: "joe: Message2" }, - ], - ) - end - - it "translates tool_call and tool messages" do - expect(context.multi_turn_scenario).to eq( - [ - { role: "system", content: context.system_insts }, - { role: "user", content: "This is a message by a user", name: "user1" }, - { role: "assistant", content: "I'm a previous bot reply, that's why there's no user" }, - { role: "user", name: "user1", content: "This is a new message by a user" }, - { - role: "assistant", - content: nil, - tool_calls: [ - { - type: "function", - function: { - name: "get_weather", - arguments: { location: "Sydney", unit: "c" }.to_json, - }, - id: "tool_id", - }, - ], - }, - { - role: "tool", - content: "I'm a tool result".to_json, - tool_call_id: "tool_id", - name: "get_weather", - }, - ], - ) - end - - it "trims content if it's getting too long" do - translated = context.long_user_input_scenario - - expect(translated.last[:role]).to eq("user") - expect(translated.last[:content].length).to be < context.long_message_text.length - end - - it "always preserves system message when trimming" do - # gpt-4 is 8k tokens so last message totally blows everything - prompt = DiscourseAi::Completions::Prompt.new("You are a bot") - prompt.push(type: :user, content: "a " * 100) - prompt.push(type: :model, content: "b " * 100) - prompt.push(type: :user, content: "zjk " * 10_000) - - translated = context.dialect(prompt).translate - - expect(translated.length).to eq(2) - expect(translated.first).to eq(content: "You are a bot", role: "system") - expect(translated.last[:role]).to eq("user") - expect(translated.last[:content].length).to be < (8000 * 4) - end - end - - describe "#tools" do - it "returns a list of available tools" do - open_ai_tool_f = { - function: { - description: context.tools.first.description, - name: context.tools.first.name, - parameters: { - properties: - context - .tools - .first - .parameters - .reduce({}) do |memo, p| - memo[p.name] = { description: p.description, type: p.type } - - memo[p.name][:enum] = p.enum if p.enum - - memo - end, - required: %w[location unit], - type: "object", - }, - }, - type: "function", - } - - expect(context.dialect_tools).to contain_exactly(open_ai_tool_f) - end - end -end diff --git a/spec/lib/completions/dialects/claude_spec.rb b/spec/lib/completions/dialects/claude_spec.rb deleted file mode 100644 index 527351db..00000000 --- a/spec/lib/completions/dialects/claude_spec.rb +++ /dev/null @@ -1,141 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::Dialects::Claude do - fab!(:llm_model) { Fabricate(:anthropic_model, name: "claude-3-opus") } - - let(:opus_dialect_klass) { DiscourseAi::Completions::Dialects::Dialect.dialect_for(llm_model) } - - before { enable_current_plugin } - - describe "#translate" do - it "can insert OKs to make stuff interleve properly" do - messages = [ - { type: :user, id: "user1", content: "1" }, - { type: :model, content: "2" }, - { type: :user, id: "user1", content: "4" }, - { type: :user, id: "user1", content: "5" }, - { type: :model, content: "6" }, - ] - - prompt = DiscourseAi::Completions::Prompt.new("You are a helpful bot", messages: messages) - - dialect = opus_dialect_klass.new(prompt, llm_model) - translated = dialect.translate - - expected_messages = [ - { role: "user", content: "user1: 1" }, - { role: "assistant", content: "2" }, - { role: "user", content: "user1: 4" }, - { role: "assistant", content: "OK" }, - { role: "user", content: "user1: 5" }, - { role: "assistant", content: "6" }, - ] - - expect(translated.messages).to eq(expected_messages) - end - - it "can properly translate a prompt (legacy tools)" do - llm_model.provider_params["disable_native_tools"] = true - llm_model.save! - - tools = [ - { - name: "echo", - description: "echo a string", - parameters: [ - { name: "text", type: "string", description: "string to echo", required: true }, - ], - }, - ] - - tool_call_prompt = { name: "echo", arguments: { text: "something" } } - - messages = [ - { type: :user, id: "user1", content: "echo something" }, - { type: :tool_call, name: "echo", id: "tool_id", content: tool_call_prompt.to_json }, - { type: :tool, id: "tool_id", content: "something".to_json }, - { type: :model, content: "I did it" }, - { type: :user, id: "user1", content: "echo something else" }, - ] - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a helpful bot", - messages: messages, - tools: tools, - ) - - dialect = opus_dialect_klass.new(prompt, llm_model) - translated = dialect.translate - - expect(translated.system_prompt).to start_with("You are a helpful bot") - - expected = [ - { role: "user", content: "user1: echo something" }, - { - role: "assistant", - content: - "<function_calls>\n<invoke>\n<tool_name>echo</tool_name>\n<parameters>\n<text>something</text>\n</parameters>\n</invoke>\n</function_calls>", - }, - { - role: "user", - content: - "<function_results>\n<result>\n<tool_name>tool_id</tool_name>\n<json>\n\"something\"\n</json>\n</result>\n</function_results>", - }, - { role: "assistant", content: "I did it" }, - { role: "user", content: "user1: echo something else" }, - ] - expect(translated.messages).to eq(expected) - end - - it "can properly translate a prompt (native tools)" do - tools = [ - { - name: "echo", - description: "echo a string", - parameters: [ - { name: "text", type: "string", description: "string to echo", required: true }, - ], - }, - ] - - tool_call_prompt = { name: "echo", arguments: { text: "something" } } - - messages = [ - { type: :user, id: "user1", content: "echo something" }, - { type: :tool_call, name: "echo", id: "tool_id", content: tool_call_prompt.to_json }, - { type: :tool, id: "tool_id", content: "something".to_json }, - { type: :model, content: "I did it" }, - { type: :user, id: "user1", content: "echo something else" }, - ] - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a helpful bot", - messages: messages, - tools: tools, - ) - dialect = opus_dialect_klass.new(prompt, llm_model) - translated = dialect.translate - - expect(translated.system_prompt).to start_with("You are a helpful bot") - - expected = [ - { role: "user", content: "user1: echo something" }, - { - role: "assistant", - content: [ - { type: "tool_use", id: "tool_id", name: "echo", input: { text: "something" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "tool_id", content: "\"something\"" }], - }, - { role: "assistant", content: "I did it" }, - { role: "user", content: "user1: echo something else" }, - ] - expect(translated.messages).to eq(expected) - end - end -end diff --git a/spec/lib/completions/dialects/dialect_context.rb b/spec/lib/completions/dialects/dialect_context.rb deleted file mode 100644 index c48c92d9..00000000 --- a/spec/lib/completions/dialects/dialect_context.rb +++ /dev/null @@ -1,120 +0,0 @@ -# frozen_string_literal: true - -class DialectContext - def initialize(dialect_klass, llm_model) - @dialect_klass = dialect_klass - @llm_model = llm_model - end - - def dialect(prompt) - @dialect_klass.new(prompt, @llm_model) - end - - def prompt - DiscourseAi::Completions::Prompt.new(system_insts, tools: tools) - end - - def dialect_tools - dialect(prompt).tools - end - - def system_user_scenario - a_prompt = prompt - a_prompt.push(type: :user, content: simple_user_input) - - dialect(a_prompt).translate - end - - def image_generation_scenario - context_and_multi_turn = [ - { type: :user, id: "user1", content: "draw a cat" }, - { - type: :tool_call, - id: "tool_id", - content: { name: "draw", arguments: { picture: "Cat" } }.to_json, - }, - { type: :tool, id: "tool_id", content: "I'm a tool result".to_json }, - { type: :user, id: "user1", content: "draw another cat" }, - ] - - a_prompt = prompt - context_and_multi_turn.each { |msg| a_prompt.push(**msg) } - - dialect(a_prompt).translate - end - - def multi_turn_scenario - context_and_multi_turn = [ - { type: :user, id: "user1", content: "This is a message by a user" }, - { type: :model, content: "I'm a previous bot reply, that's why there's no user" }, - { type: :user, id: "user1", content: "This is a new message by a user" }, - { - type: :tool_call, - id: "tool_id", - name: "get_weather", - content: { arguments: { location: "Sydney", unit: "c" } }.to_json, - }, - { type: :tool, id: "tool_id", name: "get_weather", content: "I'm a tool result".to_json }, - ] - - a_prompt = prompt - context_and_multi_turn.each { |msg| a_prompt.push(**msg) } - - dialect(a_prompt).translate - end - - def long_user_input_scenario(length: 1_000) - long_message = long_message_text(length: length) - a_prompt = prompt - a_prompt.push(type: :user, content: long_message, id: "user1") - - dialect(a_prompt).translate - end - - def long_message_text(length: 1_000) - "This a message by a user" * length - end - - def simple_user_input - <<~TEXT - Here is the text, inside <input></input> XML tags: - <input> - To perfect his horror, Caesar, surrounded at the base of the statue by the impatient daggers of his friends, - discovers among the faces and blades that of Marcus Brutus, his protege, perhaps his son, and he no longer - defends himself, but instead exclaims: 'You too, my son!' Shakespeare and Quevedo capture the pathetic cry. - - Destiny favors repetitions, variants, symmetries; nineteen centuries later, in the southern province of Buenos Aires, - a gaucho is attacked by other gauchos and, as he falls, recognizes a godson of his and says with gentle rebuke and - slow surprise (these words must be heard, not read): 'But, my friend!' He is killed and does not know that he - dies so that a scene may be repeated. - </input> - TEXT - end - - def system_insts - <<~TEXT - I want you to act as a title generator for written pieces. I will provide you with a text, - and you will generate five attention-grabbing titles. Please keep the title concise and under 20 words, - and ensure that the meaning is maintained. Replies will utilize the language type of the topic. - TEXT - end - - def tools - [ - { - name: "get_weather", - description: "Get the weather in a city", - parameters: [ - { name: "location", type: "string", description: "the city name", required: true }, - { - name: "unit", - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - required: true, - }, - ], - }, - ].map { |tool| DiscourseAi::Completions::ToolDefinition.from_hash(tool) } - end -end diff --git a/spec/lib/completions/dialects/dialect_spec.rb b/spec/lib/completions/dialects/dialect_spec.rb deleted file mode 100644 index 7e3db718..00000000 --- a/spec/lib/completions/dialects/dialect_spec.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -class TestDialect < DiscourseAi::Completions::Dialects::Dialect - attr_accessor :max_prompt_tokens - - def trim(messages) - trim_messages(messages) - end - - def system_msg(msg) - msg - end - - def user_msg(msg) - msg - end - - def model_msg(msg) - msg - end - - def tokenizer - DiscourseAi::Tokenizer::OpenAiTokenizer - end -end - -RSpec.describe DiscourseAi::Completions::Dialects::Dialect do - fab!(:llm_model) - - before { enable_current_plugin } - - describe "#translate" do - let(:five_token_msg) { "This represents five tokens." } - let(:tools) do - [ - { - name: "echo", - description: "echo a string", - parameters: [ - { name: "text", type: "string", description: "string to echo", required: true }, - ], - }, - ] - end - - it "injects done message when tool_choice is :none and last message follows tool pattern" do - tool_call_prompt = { name: "echo", arguments: { text: "test message" } } - - prompt = DiscourseAi::Completions::Prompt.new("System instructions", tools: tools) - prompt.push(type: :user, content: "echo test message") - prompt.push(type: :tool_call, content: tool_call_prompt.to_json, id: "123", name: "echo") - prompt.push(type: :tool, content: "test message".to_json, name: "echo", id: "123") - prompt.tool_choice = :none - - dialect = TestDialect.new(prompt, llm_model) - dialect.max_prompt_tokens = 100 # Set high enough to avoid trimming - - translated = dialect.translate - - expect(translated).to eq( - [ - { type: :system, content: "System instructions" }, - { type: :user, content: "echo test message" }, - { - type: :tool_call, - content: - "<function_calls>\n<invoke>\n<tool_name>echo</tool_name>\n<parameters>\n<text>test message</text>\n</parameters>\n</invoke>\n</function_calls>", - id: "123", - name: "echo", - }, - { - type: :tool, - id: "123", - name: "echo", - content: - "<function_results>\n<result>\n<tool_name>echo</tool_name>\n<json>\n\"test message\"\n</json>\n</result>\n</function_results>\n\n#{::DiscourseAi::Completions::Dialects::XmlTools::DONE_MESSAGE}", - }, - ], - ) - end - end - - describe "#trim_messages" do - let(:five_token_msg) { "This represents five tokens." } - - it "should trim tool messages if tool_calls are trimmed" do - prompt = DiscourseAi::Completions::Prompt.new(five_token_msg) - prompt.push(type: :user, content: five_token_msg) - prompt.push(type: :tool_call, content: five_token_msg, id: 1) - prompt.push(type: :tool, content: five_token_msg, id: 1) - prompt.push(type: :user, content: five_token_msg) - - dialect = TestDialect.new(prompt, llm_model) - dialect.max_prompt_tokens = 15 # fits the user messages and the tool_call message - - trimmed = dialect.trim(prompt.messages) - - expect(trimmed).to eq( - [{ type: :system, content: five_token_msg }, { type: :user, content: five_token_msg }], - ) - end - - it "limits the system message to 60% of available tokens" do - prompt = - DiscourseAi::Completions::Prompt.new("I'm a system message consisting of 10 tokens okay") - prompt.push(type: :user, content: five_token_msg) - - dialect = TestDialect.new(prompt, llm_model) - dialect.max_prompt_tokens = 15 - - trimmed = dialect.trim(prompt.messages) - - expect(trimmed).to eq( - [ - { type: :system, content: "I'm a system message consisting of 10 tokens" }, - { type: :user, content: five_token_msg }, - ], - ) - end - end -end diff --git a/spec/lib/completions/dialects/gemini_spec.rb b/spec/lib/completions/dialects/gemini_spec.rb deleted file mode 100644 index 404e8b60..00000000 --- a/spec/lib/completions/dialects/gemini_spec.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -require_relative "dialect_context" - -RSpec.describe DiscourseAi::Completions::Dialects::Gemini do - fab!(:model) { Fabricate(:gemini_model) } - let(:context) { DialectContext.new(described_class, model) } - - before { enable_current_plugin } - - describe "#translate" do - it "translates a prompt written in our generic format to the Gemini format" do - gemini_version = { - messages: [{ role: "user", parts: [{ text: context.simple_user_input }] }], - system_instruction: context.system_insts, - } - - translated = context.system_user_scenario - - expect(translated).to eq(gemini_version) - end - - it "injects model after tool call" do - expect(context.image_generation_scenario).to eq( - { - messages: [ - { role: "user", parts: [{ text: "user1: draw a cat" }] }, - { - role: "model", - parts: [{ functionCall: { name: "draw", args: { picture: "Cat" } } }], - }, - { - role: "function", - parts: [ - { - functionResponse: { - name: "tool_id", - response: { - content: "\"I'm a tool result\"", - }, - }, - }, - ], - }, - { role: "model", parts: { text: "Ok." } }, - { role: "user", parts: [{ text: "user1: draw another cat" }] }, - ], - system_instruction: context.system_insts, - }, - ) - end - - it "translates tool_call and tool messages" do - expect(context.multi_turn_scenario).to eq( - { - messages: [ - { role: "user", parts: [{ text: "user1: This is a message by a user" }] }, - { - role: "model", - parts: [{ text: "I'm a previous bot reply, that's why there's no user" }], - }, - { role: "user", parts: [{ text: "user1: This is a new message by a user" }] }, - { - role: "model", - parts: [ - { functionCall: { name: "get_weather", args: { location: "Sydney", unit: "c" } } }, - ], - }, - { - role: "function", - parts: [ - { - functionResponse: { - name: "get_weather", - response: { - content: "\"I'm a tool result\"", - }, - }, - }, - ], - }, - ], - system_instruction: - "I want you to act as a title generator for written pieces. I will provide you with a text,\nand you will generate five attention-grabbing titles. Please keep the title concise and under 20 words,\nand ensure that the meaning is maintained. Replies will utilize the language type of the topic.\n", - }, - ) - end - - it "trims content if it's getting too long" do - # testing truncation on 800k tokens is slow use model with less - model.max_prompt_tokens = 16_384 - context = DialectContext.new(described_class, model) - translated = context.long_user_input_scenario(length: 5_000) - - expect(translated[:messages].last[:role]).to eq("user") - expect(translated[:messages].last.dig(:parts, 0, :text).length).to be < - context.long_message_text(length: 5_000).length - end - end - - describe "#tools" do - it "returns a list of available tools" do - gemini_tools = { - function_declarations: [ - { - name: "get_weather", - description: "Get the weather in a city", - parameters: { - type: "object", - required: %w[location unit], - properties: { - "location" => { - type: :string, - description: "the city name", - }, - "unit" => { - type: :string, - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - }, - }, - }, - }, - ], - } - expect(context.dialect_tools).to contain_exactly(gemini_tools) - end - end -end diff --git a/spec/lib/completions/dialects/mistral_spec.rb b/spec/lib/completions/dialects/mistral_spec.rb deleted file mode 100644 index b49ed257..00000000 --- a/spec/lib/completions/dialects/mistral_spec.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" -require_relative "dialect_context" - -RSpec.describe DiscourseAi::Completions::Dialects::Mistral do - fab!(:model) { Fabricate(:mistral_model) } - let(:context) { DialectContext.new(described_class, model) } - let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } - let(:upload100x100) do - UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) - end - - before { enable_current_plugin } - - it "does not include user names" do - prompt = - DiscourseAi::Completions::Prompt.new( - messages: [type: :user, content: "Hello, I am Bob", id: "bob"], - ) - - dialect = described_class.new(prompt, model) - - # mistral has no support for name - expect(dialect.translate).to eq([{ role: "user", content: "bob: Hello, I am Bob" }]) - end - - it "can properly encode images" do - model.update!(vision_enabled: true) - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are image bot", - messages: [type: :user, id: "user1", content: ["hello", { upload_id: upload100x100.id }]], - ) - - encoded = prompt.encoded_uploads(prompt.messages.last) - - image = "data:image/jpeg;base64,#{encoded[0][:base64]}" - - dialect = described_class.new(prompt, model) - - content = dialect.translate[1][:content] - - expect(content).to eq( - [{ type: "text", text: "user1: hello" }, { type: "image_url", image_url: { url: image } }], - ) - end - - it "can properly map tool calls to mistral format" do - result = [ - { - role: "system", - content: - "I want you to act as a title generator for written pieces. I will provide you with a text,\nand you will generate five attention-grabbing titles. Please keep the title concise and under 20 words,\nand ensure that the meaning is maintained. Replies will utilize the language type of the topic.\n", - }, - { role: "user", content: "user1: This is a message by a user" }, - { role: "assistant", content: "I'm a previous bot reply, that's why there's no user" }, - { role: "user", content: "user1: This is a new message by a user" }, - { - role: "assistant", - content: "", - tool_calls: [ - { - type: "function", - function: { - arguments: "{\"location\":\"Sydney\",\"unit\":\"c\"}", - name: "get_weather", - }, - id: "tool_id", - }, - ], - }, - { - role: "tool", - tool_call_id: "tool_id", - content: "\"I'm a tool result\"", - name: "get_weather", - }, - ] - expect(context.multi_turn_scenario).to eq(result) - end -end diff --git a/spec/lib/completions/dialects/nova_spec.rb b/spec/lib/completions/dialects/nova_spec.rb deleted file mode 100644 index ad4d3f5a..00000000 --- a/spec/lib/completions/dialects/nova_spec.rb +++ /dev/null @@ -1,197 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::Dialects::Nova do - fab!(:llm_model) { Fabricate(:nova_model, vision_enabled: true) } - - let(:nova_dialect_klass) { DiscourseAi::Completions::Dialects::Dialect.dialect_for(llm_model) } - - before { enable_current_plugin } - - it "finds the right dialect" do - expect(nova_dialect_klass).to eq(DiscourseAi::Completions::Dialects::Nova) - end - - describe "#translate" do - it "properly formats a basic conversation" do - messages = [ - { type: :user, id: "user1", content: "Hello" }, - { type: :model, content: "Hi there!" }, - ] - - prompt = DiscourseAi::Completions::Prompt.new("You are a helpful bot", messages: messages) - dialect = nova_dialect_klass.new(prompt, llm_model) - translated = dialect.translate - - expect(translated.system).to eq([{ text: "You are a helpful bot" }]) - expect(translated.messages).to eq( - [ - { role: "user", content: [{ text: "Hello" }] }, - { role: "assistant", content: [{ text: "Hi there!" }] }, - ], - ) - end - - context "with image content" do - let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } - let(:upload) do - UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) - end - - it "properly formats messages with images" do - messages = [ - { - type: :user, - id: "user1", - content: ["What's in this image?", { upload_id: upload.id }], - }, - ] - - prompt = DiscourseAi::Completions::Prompt.new(messages: messages) - - dialect = nova_dialect_klass.new(prompt, llm_model) - translated = dialect.translate - - encoded = prompt.encoded_uploads(messages.first).first[:base64] - - expect(translated.messages.first[:content]).to eq( - [ - { text: "What's in this image?" }, - { image: { format: "jpeg", source: { bytes: encoded } } }, - ], - ) - end - end - - context "with tools" do - it "properly formats tool configuration" do - tools = [ - { - name: "get_weather", - description: "Get the weather in a city", - parameters: [ - { name: "location", type: "string", description: "the city name", required: true }, - ], - }, - ] - - messages = [{ type: :user, content: "What's the weather?" }] - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a helpful bot", - messages: messages, - tools: tools, - ) - - dialect = nova_dialect_klass.new(prompt, llm_model) - translated = dialect.translate - - expected = { - tools: [ - { - toolSpec: { - name: "get_weather", - description: "Get the weather in a city", - inputSchema: { - json: { - type: "object", - properties: { - "location" => { - type: :string, - description: "the city name", - }, - }, - required: ["location"], - }, - }, - }, - }, - ], - } - - expect(translated.tool_config).to eq(expected) - end - end - - context "with inference configuration" do - it "includes inference configuration when provided" do - messages = [{ type: :user, content: "Hello" }] - - prompt = DiscourseAi::Completions::Prompt.new("You are a helpful bot", messages: messages) - - dialect = nova_dialect_klass.new(prompt, llm_model) - - options = { temperature: 0.7, top_p: 0.9, max_tokens: 100, stop_sequences: ["STOP"] } - - translated = dialect.translate - - expected = { - system: [{ text: "You are a helpful bot" }], - messages: [{ role: "user", content: [{ text: "Hello" }] }], - inferenceConfig: { - temperature: 0.7, - top_p: 0.9, - stopSequences: ["STOP"], - max_new_tokens: 100, - }, - } - - expect(translated.to_payload(options)).to eq(expected) - end - - it "omits inference configuration when not provided" do - messages = [{ type: :user, content: "Hello" }] - - prompt = DiscourseAi::Completions::Prompt.new("You are a helpful bot", messages: messages) - - dialect = nova_dialect_klass.new(prompt, llm_model) - translated = dialect.translate - - expect(translated.inference_config).to be_nil - end - end - - it "handles tool calls and responses" do - tool_call_prompt = { name: "get_weather", arguments: { location: "London" } } - - messages = [ - { type: :user, id: "user1", content: "What's the weather in London?" }, - { type: :tool_call, name: "get_weather", id: "tool_id", content: tool_call_prompt.to_json }, - { type: :tool, id: "tool_id", content: "Sunny, 22°C".to_json }, - { type: :model, content: "The weather in London is sunny with 22°C" }, - ] - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a helpful bot", - messages: messages, - tools: [ - { - name: "get_weather", - description: "Get the weather in a city", - parameters: [ - { name: "location", type: "string", description: "the city name", required: true }, - ], - }, - ], - ) - - dialect = nova_dialect_klass.new(prompt, llm_model) - translated = dialect.translate - - expect(translated.messages.map { |m| m[:role] }).to eq(%w[user assistant user assistant]) - expect(translated.messages.last[:content]).to eq( - [{ text: "The weather in London is sunny with 22°C" }], - ) - end - end - - describe "#max_prompt_tokens" do - it "returns the model's max prompt tokens" do - prompt = DiscourseAi::Completions::Prompt.new("You are a helpful bot") - dialect = nova_dialect_klass.new(prompt, llm_model) - - expect(dialect.max_prompt_tokens).to eq(llm_model.max_prompt_tokens) - end - end -end diff --git a/spec/lib/completions/dialects/ollama_spec.rb b/spec/lib/completions/dialects/ollama_spec.rb deleted file mode 100644 index e0a2e8e1..00000000 --- a/spec/lib/completions/dialects/ollama_spec.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -require_relative "dialect_context" - -RSpec.describe DiscourseAi::Completions::Dialects::Ollama do - fab!(:model) { Fabricate(:ollama_model) } - let(:context) { DialectContext.new(described_class, model) } - let(:dialect_class) { DiscourseAi::Completions::Dialects::Dialect.dialect_for(model) } - - before { enable_current_plugin } - - describe "#translate" do - context "when native tool support is enabled" do - it "translates a prompt written in our generic format to the Ollama format" do - ollama_version = [ - { role: "system", content: context.system_insts }, - { role: "user", content: context.simple_user_input }, - ] - - translated = context.system_user_scenario - - expect(translated).to eq(ollama_version) - end - end - - context "when native tool support is disabled - XML tools" do - it "includes the instructions in the system message" do - allow(model).to receive(:lookup_custom_param).with("enable_native_tool").and_return(false) - - DiscourseAi::Completions::Dialects::XmlTools - .any_instance - .stubs(:instructions) - .returns("Instructions") - - ollama_version = [ - { role: "system", content: "#{context.system_insts}\n\nInstructions" }, - { role: "user", content: context.simple_user_input }, - ] - - translated = context.system_user_scenario - - expect(translated).to eq(ollama_version) - end - end - - it "trims content if it's getting too long" do - model.max_prompt_tokens = 5000 - translated = context.long_user_input_scenario - - expect(translated.last[:role]).to eq("user") - expect(translated.last[:content].length).to be < context.long_message_text.length - end - end - - describe "#max_prompt_tokens" do - it "returns the max_prompt_tokens from the llm_model" do - model.max_prompt_tokens = 10_000 - expect(context.dialect(nil).max_prompt_tokens).to eq(10_000) - end - end - - describe "#tools" do - context "when native tools are enabled" do - it "returns the translated tools from the OllamaTools class" do - model.update!(provider_params: { enable_native_tool: true }) - - tool = { name: "noop", description: "do nothing" } - messages = [ - { type: :user, content: "echo away" }, - { type: :tool_call, content: "{}", name: "noop" }, - { type: :tool, content: "{}", name: "noop" }, - ] - prompt = DiscourseAi::Completions::Prompt.new("a bot", tools: [tool], messages: messages) - dialect = dialect_class.new(prompt, model) - - expected = [ - { role: "system", content: "a bot" }, - { role: "user", content: "echo away" }, - { - role: "assistant", - content: nil, - tool_calls: [{ type: "function", function: { name: "noop" } }], - }, - { role: "tool", content: "{}", name: "noop" }, - ] - expect(dialect.translate).to eq(expected) - end - end - - context "when native tools are disabled" do - it "returns the translated tools from the XmlTools class" do - model.update!(provider_params: { enable_native_tool: false }) - - tool = { name: "noop", description: "do nothing" } - messages = [ - { type: :user, content: "echo away" }, - { type: :tool_call, content: "{}", name: "noop" }, - { type: :tool, content: "{}", name: "noop" }, - ] - prompt = DiscourseAi::Completions::Prompt.new("a bot", tools: [tool], messages: messages) - dialect = dialect_class.new(prompt, model) - - expected = %w[system user assistant user] - roles = dialect.translate.map { |x| x[:role] } - - # notice, no tool role - expect(roles).to eq(expected) - end - end - end -end diff --git a/spec/lib/completions/dialects/ollama_tools_spec.rb b/spec/lib/completions/dialects/ollama_tools_spec.rb deleted file mode 100644 index 18d07229..00000000 --- a/spec/lib/completions/dialects/ollama_tools_spec.rb +++ /dev/null @@ -1,116 +0,0 @@ -# frozen_string_literal: true - -require_relative "dialect_context" - -RSpec.describe DiscourseAi::Completions::Dialects::OllamaTools do - before { enable_current_plugin } - - describe "#translated_tools" do - it "translates a tool from our generic format to the Ollama format" do - tool = { - name: "github_file_content", - description: "Retrieves the content of specified GitHub files", - parameters: [ - { - name: "repo_name", - description: "The name of the GitHub repository (e.g., 'discourse/discourse')", - type: "string", - required: true, - }, - { - name: "file_paths", - description: "The paths of the files to retrieve within the repository", - type: "array", - item_type: "string", - required: true, - }, - { - name: "branch", - description: "The branch or commit SHA to retrieve the files from (default: 'main')", - type: "string", - required: false, - }, - ], - } - - tools = [DiscourseAi::Completions::ToolDefinition.from_hash(tool)] - ollama_tools = described_class.new(tools) - - translated_tools = ollama_tools.translated_tools - - expect(translated_tools).to eq( - [ - { - type: "function", - function: { - name: "github_file_content", - description: "Retrieves the content of specified GitHub files", - parameters: { - type: "object", - properties: { - "repo_name" => { - description: "The name of the GitHub repository (e.g., 'discourse/discourse')", - type: :string, - }, - "file_paths" => { - description: "The paths of the files to retrieve within the repository", - type: :array, - items: { - type: :string, - }, - }, - "branch" => { - description: - "The branch or commit SHA to retrieve the files from (default: 'main')", - type: :string, - }, - }, - required: %w[repo_name file_paths], - }, - }, - }, - ], - ) - end - end - - describe "#from_raw_tool_call" do - it "converts a raw tool call to the Ollama tool format" do - raw_message = { - content: '{"repo_name":"discourse/discourse","file_paths":["README.md"],"branch":"main"}', - } - - ollama_tools = described_class.new([]) - tool_call = ollama_tools.from_raw_tool_call(raw_message) - - expect(tool_call).to eq( - { - role: "assistant", - content: nil, - tool_calls: [ - { - type: "function", - function: { - repo_name: "discourse/discourse", - file_paths: ["README.md"], - branch: "main", - name: nil, - }, - }, - ], - }, - ) - end - end - - describe "#from_raw_tool" do - it "converts a raw tool to the Ollama tool format" do - raw_message = { content: "Hello, world!", name: "github_file_content" } - - ollama_tools = described_class.new([]) - tool = ollama_tools.from_raw_tool(raw_message) - - expect(tool).to eq({ role: "tool", content: "Hello, world!", name: "github_file_content" }) - end - end -end diff --git a/spec/lib/completions/dialects/open_ai_compatible_spec.rb b/spec/lib/completions/dialects/open_ai_compatible_spec.rb deleted file mode 100644 index 1e66b6eb..00000000 --- a/spec/lib/completions/dialects/open_ai_compatible_spec.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::Dialects::OpenAiCompatible do - before { enable_current_plugin } - - context "when system prompts are disabled" do - fab!(:model) do - Fabricate(:vllm_model, vision_enabled: true, provider_params: { disable_system_prompt: true }) - end - - it "merges the system prompt into the first message" do - system_msg = "This is a system message" - user_msg = "user message" - prompt = - DiscourseAi::Completions::Prompt.new( - system_msg, - messages: [{ type: :user, content: user_msg }], - ) - - translated_messages = described_class.new(prompt, model).translate - - expect(translated_messages.length).to eq(1) - expect(translated_messages).to contain_exactly( - { role: "user", content: [system_msg, user_msg].join("\n") }, - ) - end - - context "when the prompt has inline images" do - let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } - - it "produces a valid message" do - upload = UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot specializing in image captioning.", - messages: [ - { - type: :user, - content: ["Describe this image in a single sentence.", { upload_id: upload.id }], - }, - ], - ) - encoded_upload = - DiscourseAi::Completions::UploadEncoder.encode( - upload_ids: [upload.id], - max_pixels: prompt.max_pixels, - ).first - - translated_messages = described_class.new(prompt, model).translate - - expect(translated_messages.length).to eq(1) - - # no system message support here - expected_user_message = { - role: "user", - content: [ - { - type: "text", - text: - "You are a bot specializing in image captioning.\nDescribe this image in a single sentence.", - }, - { - type: "image_url", - image_url: { - url: "data:#{encoded_upload[:mime_type]};base64,#{encoded_upload[:base64]}", - }, - }, - ], - } - - expect(translated_messages).to contain_exactly(expected_user_message) - end - end - end - - context "when system prompts are enabled" do - it "includes system and user messages separately" do - system_msg = "This is a system message" - user_msg = "user message" - prompt = - DiscourseAi::Completions::Prompt.new( - system_msg, - messages: [{ type: :user, content: user_msg }], - ) - - model = Fabricate(:vllm_model, provider_params: { disable_system_prompt: false }) - - translated_messages = described_class.new(prompt, model).translate - - expect(translated_messages.length).to eq(2) - expect(translated_messages).to contain_exactly( - { role: "system", content: system_msg }, - { role: "user", content: user_msg }, - ) - end - end -end diff --git a/spec/lib/completions/endpoints/anthropic_spec.rb b/spec/lib/completions/endpoints/anthropic_spec.rb deleted file mode 100644 index a454fcf2..00000000 --- a/spec/lib/completions/endpoints/anthropic_spec.rb +++ /dev/null @@ -1,959 +0,0 @@ -# frozen_string_literal: true -require_relative "endpoint_compliance" - -RSpec.describe DiscourseAi::Completions::Endpoints::Anthropic do - let(:url) { "https://api.anthropic.com/v1/messages" } - fab!(:model) { Fabricate(:anthropic_model, name: "claude-3-opus", vision_enabled: true) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") } - let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } - let(:upload100x100) do - UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) - end - - let(:prompt) do - DiscourseAi::Completions::Prompt.new( - "You are hello bot", - messages: [type: :user, id: "user1", content: "hello"], - ) - end - - let(:echo_tool) do - { - name: "echo", - description: "echo something", - parameters: [{ name: "text", type: "string", description: "text to echo", required: true }], - } - end - - let(:google_tool) do - { - name: "google", - description: "google something", - parameters: [ - { name: "query", type: "string", description: "text to google", required: true }, - ], - } - end - - let(:prompt_with_echo_tool) do - prompt_with_tools = prompt - prompt.tools = [echo_tool] - prompt_with_tools - end - - let(:prompt_with_google_tool) do - prompt_with_tools = prompt - prompt.tools = [echo_tool] - prompt_with_tools - end - - before { enable_current_plugin } - - it "does not eat spaces with tool calls" do - body = <<~STRING - event: message_start - data: {"type":"message_start","message":{"id":"msg_01Ju4j2MiGQb9KV9EEQ522Y3","type":"message","role":"assistant","model":"claude-3-haiku-20240307","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1293,"output_tokens":1}} } - - event: content_block_start - data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01DjrShFRRHp9SnHYRFRc53F","name":"search","input":{}} } - - event: ping - data: {"type": "ping"} - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"searc"} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"h_qu"} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"er"} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"y\\": \\"s"} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"<a>m"} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" "} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"sam\\""} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":", \\"cate"} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"gory"} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\\": \\"gene"} } - - event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ral\\"}"} } - - event: content_block_stop - data: {"type":"content_block_stop","index":0 } - - event: message_delta - data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":70} } - - event: message_stop - data: {"type":"message_stop"} - STRING - - result = [] - body = body.scan(/.*\n/) - EndpointMock.with_chunk_array_support do - stub_request(:post, url).to_return(status: 200, body: body) - - llm.generate( - prompt_with_google_tool, - user: Discourse.system_user, - partial_tool_calls: true, - ) { |partial| result << partial.dup } - end - - tool_call = - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "toolu_01DjrShFRRHp9SnHYRFRc53F", - parameters: { - search_query: "s<a>m sam", - category: "general", - }, - ) - - expect(result.last).to eq(tool_call) - - search_queries = result.filter(&:partial).map { |r| r.parameters[:search_query] } - categories = result.filter(&:partial).map { |r| r.parameters[:category] } - - expect(categories).to eq([nil, nil, nil, nil, "gene", "general"]) - expect(search_queries).to eq(["s", "s<a>m", "s<a>m ", "s<a>m sam", "s<a>m sam", "s<a>m sam"]) - end - - it "can stream a response" do - body = (<<~STRING).strip - event: message_start - data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-opus-20240229", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - - event: content_block_start - data: {"type": "content_block_start", "index":0, "content_block": {"type": "text", "text": ""}} - - event: ping - data: {"type": "ping"} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "!"}} - - event: content_block_stop - data: {"type": "content_block_stop", "index": 0} - - event: message_delta - data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null, "usage":{"output_tokens": 15}}} - - event: message_stop - data: {"type": "message_stop"} - STRING - - parsed_body = nil - - stub_request(:post, url).with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return(status: 200, body: body) - - result = +"" - llm.generate(prompt, user: Discourse.system_user, feature_name: "testing") do |partial, cancel| - result << partial - end - - expect(result).to eq("Hello!") - - expected_body = { - model: "claude-3-opus-20240229", - max_tokens: 4096, - messages: [{ role: "user", content: "user1: hello" }], - system: "You are hello bot", - stream: true, - } - expect(parsed_body).to eq(expected_body) - - log = AiApiAuditLog.order(:id).last - expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) - expect(log.feature_name).to eq("testing") - expect(log.response_tokens).to eq(15) - expect(log.request_tokens).to eq(25) - expect(log.raw_request_payload).to eq(expected_body.to_json) - expect(log.raw_response_payload.strip).to eq(body.strip) - end - - it "supports non streaming tool calls" do - tool = { - name: "calculate", - description: "calculate something", - parameters: [ - { - name: "expression", - type: "string", - description: "expression to calculate", - required: true, - }, - ], - } - - prompt = - DiscourseAi::Completions::Prompt.new( - "You a calculator", - messages: [{ type: :user, id: "user1", content: "calculate 2758975 + 21.11" }], - tools: [tool], - ) - - body = { - id: "msg_01RdJkxCbsEj9VFyFYAkfy2S", - type: "message", - role: "assistant", - model: "claude-3-haiku-20240307", - content: [ - { type: "text", text: "Here is the calculation:" }, - { - type: "tool_use", - id: "toolu_012kBdhG4eHaV68W56p4N94h", - name: "calculate", - input: { - expression: "2758975 + 21.11", - }, - }, - ], - stop_reason: "tool_use", - stop_sequence: nil, - usage: { - input_tokens: 345, - output_tokens: 65, - }, - }.to_json - - stub_request(:post, url).to_return(body: body) - - result = llm.generate(prompt, user: Discourse.system_user) - - tool_call = - DiscourseAi::Completions::ToolCall.new( - name: "calculate", - id: "toolu_012kBdhG4eHaV68W56p4N94h", - parameters: { - expression: "2758975 + 21.11", - }, - ) - - expect(result).to eq(["Here is the calculation:", tool_call]) - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(345) - expect(log.response_tokens).to eq(65) - end - - it "can send images via a completion prompt" do - prompt = - DiscourseAi::Completions::Prompt.new( - "You are image bot", - messages: [type: :user, id: "user1", content: ["hello", { upload_id: upload100x100.id }]], - ) - - encoded = prompt.encoded_uploads(prompt.messages.last) - - request_body = { - model: "claude-3-opus-20240229", - max_tokens: 4096, - messages: [ - { - role: "user", - content: [ - { type: "text", text: "user1: hello" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: encoded[0][:base64], - }, - }, - ], - }, - ], - system: "You are image bot", - } - - response_body = <<~STRING - { - "content": [ - { - "text": "What a cool image", - "type": "text" - } - ], - "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", - "model": "claude-3-opus-20240229", - "role": "assistant", - "stop_reason": "end_turn", - "stop_sequence": null, - "type": "message", - "usage": { - "input_tokens": 10, - "output_tokens": 25 - } - } - STRING - - requested_body = nil - stub_request(:post, url).with( - body: - proc do |req_body| - requested_body = JSON.parse(req_body, symbolize_names: true) - true - end, - ).to_return(status: 200, body: response_body) - - result = llm.generate(prompt, user: Discourse.system_user) - - expect(result).to eq("What a cool image") - expect(requested_body).to eq(request_body) - end - - it "can support reasoning" do - body = <<~STRING - { - "content": [ - { - "text": "Hello!", - "type": "text" - } - ], - "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", - "model": "claude-3-opus-20240229", - "role": "assistant", - "stop_reason": "end_turn", - "stop_sequence": null, - "type": "message", - "usage": { - "input_tokens": 10, - "output_tokens": 25 - } - } - STRING - - parsed_body = nil - stub_request(:post, url).with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return(status: 200, body: body) - - model.provider_params["enable_reasoning"] = true - model.provider_params["reasoning_tokens"] = 10_000 - model.save! - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - result = proxy.generate(prompt, user: Discourse.system_user) - expect(result).to eq("Hello!") - - expected_body = { - model: "claude-3-opus-20240229", - max_tokens: 40_000, - thinking: { - type: "enabled", - budget_tokens: 10_000, - }, - messages: [{ role: "user", content: "user1: hello" }], - system: "You are hello bot", - } - expect(parsed_body).to eq(expected_body) - - log = AiApiAuditLog.order(:id).last - expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) - expect(log.request_tokens).to eq(10) - expect(log.response_tokens).to eq(25) - end - - it "can operate in regular mode" do - body = <<~STRING - { - "content": [ - { - "text": "Hello!", - "type": "text" - } - ], - "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", - "model": "claude-3-opus-20240229", - "role": "assistant", - "stop_reason": "end_turn", - "stop_sequence": null, - "type": "message", - "usage": { - "input_tokens": 10, - "output_tokens": 25 - } - } - STRING - - parsed_body = nil - stub_request(:post, url).with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return(status: 200, body: body) - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - result = proxy.generate(prompt, user: Discourse.system_user) - expect(result).to eq("Hello!") - - expected_body = { - model: "claude-3-opus-20240229", - max_tokens: 4096, - messages: [{ role: "user", content: "user1: hello" }], - system: "You are hello bot", - } - expect(parsed_body).to eq(expected_body) - - log = AiApiAuditLog.order(:id).last - expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) - expect(log.request_tokens).to eq(10) - expect(log.response_tokens).to eq(25) - end - - it "can send through thinking tokens via a completion prompt" do - body = { - id: "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", - type: "message", - role: "assistant", - content: [{ type: "text", text: "world" }], - model: "claude-3-7-sonnet-20250219", - stop_reason: "end_turn", - usage: { - input_tokens: 25, - output_tokens: 40, - }, - }.to_json - - parsed_body = nil - stub_request(:post, url).with( - body: ->(req_body) { parsed_body = JSON.parse(req_body) }, - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return(status: 200, body: body) - - prompt = DiscourseAi::Completions::Prompt.new("system prompt") - prompt.push(type: :user, content: "hello") - prompt.push( - type: :model, - id: "user1", - content: "hello", - thinking: "I am thinking", - thinking_signature: "signature", - redacted_thinking_signature: "redacted_signature", - ) - - result = llm.generate(prompt, user: Discourse.system_user) - expect(result).to eq("world") - - expected_body = { - "model" => "claude-3-opus-20240229", - "max_tokens" => 4096, - "messages" => [ - { "role" => "user", "content" => "hello" }, - { - "role" => "assistant", - "content" => [ - { "type" => "thinking", "thinking" => "I am thinking", "signature" => "signature" }, - { "type" => "redacted_thinking", "data" => "redacted_signature" }, - { "type" => "text", "text" => "hello" }, - ], - }, - ], - "system" => "system prompt", - } - - expect(parsed_body).to eq(expected_body) - end - - it "can handle a response with thinking blocks in non-streaming mode" do - body = { - id: "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", - type: "message", - role: "assistant", - content: [ - { - type: "thinking", - thinking: "This is my thinking process about prime numbers...", - signature: "abc123signature", - }, - { type: "redacted_thinking", data: "abd456signature" }, - { type: "text", text: "Yes, there are infinitely many prime numbers where n mod 4 = 3." }, - ], - model: "claude-3-7-sonnet-20250219", - stop_reason: "end_turn", - usage: { - input_tokens: 25, - output_tokens: 40, - }, - }.to_json - - stub_request(:post, url).with( - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return(status: 200, body: body) - - result = - llm.generate( - "hello", - user: Discourse.system_user, - feature_name: "testing", - output_thinking: true, - ) - - # Result should be an array with both thinking and text content - expect(result).to be_an(Array) - expect(result.length).to eq(3) - - # First item should be a Thinking object - expect(result[0]).to be_a(DiscourseAi::Completions::Thinking) - expect(result[0].message).to eq("This is my thinking process about prime numbers...") - expect(result[0].signature).to eq("abc123signature") - - expect(result[1]).to be_a(DiscourseAi::Completions::Thinking) - expect(result[1].signature).to eq("abd456signature") - expect(result[1].redacted).to eq(true) - - # Second item should be the text response - expect(result[2]).to eq("Yes, there are infinitely many prime numbers where n mod 4 = 3.") - - # Verify audit log - log = AiApiAuditLog.order(:id).last - expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) - expect(log.feature_name).to eq("testing") - expect(log.response_tokens).to eq(40) - end - - it "can stream a response with thinking blocks" do - body = (<<~STRING).strip - event: message_start - data: {"type": "message_start", "message": {"id": "msg_01...", "type": "message", "role": "assistant", "content": [], "model": "claude-3-opus-20240229", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25}}} - - event: content_block_start - data: {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "Let me solve this step by step:\\n\\n1. First break down 27 * 453"}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\\n2. 453 = 400 + 50 + 3"}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds..."}} - - event: content_block_stop - data: {"type": "content_block_stop", "index": 0} - - event: content_block_start - data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"AAA=="} } - - event: ping - data: {"type": "ping"} - - event: content_block_stop - data: {"type":"content_block_stop","index":0 } - - event: content_block_start - data: {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "27 * 453 = 12,231"}} - - event: content_block_stop - data: {"type": "content_block_stop", "index": 1} - - event: message_delta - data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "usage": {"output_tokens": 30}}} - - event: message_stop - data: {"type": "message_stop"} - STRING - - parsed_body = nil - - stub_request(:post, url).with( - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return(status: 200, body: body) - - thinking_chunks = [] - text_chunks = [] - - llm.generate( - "hello there", - user: Discourse.system_user, - feature_name: "testing", - output_thinking: true, - ) do |partial, cancel| - if partial.is_a?(DiscourseAi::Completions::Thinking) - thinking_chunks << partial - else - text_chunks << partial - end - end - - expected_thinking = [ - DiscourseAi::Completions::Thinking.new(message: "", signature: "", partial: true), - DiscourseAi::Completions::Thinking.new( - message: "Let me solve this step by step:\n\n1. First break down 27 * 453", - partial: true, - ), - DiscourseAi::Completions::Thinking.new(message: "\n2. 453 = 400 + 50 + 3", partial: true), - DiscourseAi::Completions::Thinking.new( - message: - "Let me solve this step by step:\n\n1. First break down 27 * 453\n2. 453 = 400 + 50 + 3", - signature: "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds...", - partial: false, - ), - DiscourseAi::Completions::Thinking.new(message: nil, signature: "AAA==", redacted: true), - ] - - expect(thinking_chunks).to eq(expected_thinking) - expect(text_chunks).to eq(["27 * 453 = 12,231"]) - - log = AiApiAuditLog.order(:id).last - expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) - expect(log.feature_name).to eq("testing") - expect(log.response_tokens).to eq(30) - end - - describe "max output tokens" do - it "it respects max output tokens supplied to model unconditionally, even with thinking" do - model.update!( - provider_params: { - enable_reasoning: true, - reasoning_tokens: 1000, - }, - max_output_tokens: 2000, - ) - - parsed_body = nil - stub_request(:post, url).with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return( - status: 200, - body: { - id: "msg_123", - type: "message", - role: "assistant", - content: [{ type: "text", text: "test response" }], - model: "claude-3-opus-20240229", - usage: { - input_tokens: 10, - output_tokens: 5, - }, - }.to_json, - ) - - llm.generate(prompt, user: Discourse.system_user, max_tokens: 2500) - expect(parsed_body[:max_tokens]).to eq(2000) - - llm.generate(prompt, user: Discourse.system_user) - expect(parsed_body[:max_tokens]).to eq(2000) - end - end - - describe "parameter disabling" do - it "excludes disabled parameters from the request" do - model.update!(provider_params: { disable_top_p: true, disable_temperature: true }) - - parsed_body = nil - stub_request(:post, url).with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return( - status: 200, - body: { - id: "msg_123", - type: "message", - role: "assistant", - content: [{ type: "text", text: "test response" }], - model: "claude-3-opus-20240229", - usage: { - input_tokens: 10, - output_tokens: 5, - }, - }.to_json, - ) - - # Request with parameters that should be ignored - llm.generate( - prompt, - user: Discourse.system_user, - top_p: 0.9, - temperature: 0.8, - max_tokens: 500, - ) - - # Verify disabled parameters aren't included - expect(parsed_body).not_to have_key(:top_p) - expect(parsed_body).not_to have_key(:temperature) - - # Verify other parameters still work - expect(parsed_body).to have_key(:max_tokens) - expect(parsed_body[:max_tokens]).to eq(500) - end - end - - describe "disabled tool use" do - it "can properly disable tool use with :none" do - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot", - messages: [type: :user, id: "user1", content: "don't use any tools please"], - tools: [echo_tool], - tool_choice: :none, - ) - - response_body = { - id: "msg_01RdJkxCbsEj9VFyFYAkfy2S", - type: "message", - role: "assistant", - model: "claude-3-haiku-20240307", - content: [ - { type: "text", text: "I won't use any tools. Here's a direct response instead." }, - ], - stop_reason: "end_turn", - stop_sequence: nil, - usage: { - input_tokens: 345, - output_tokens: 65, - }, - }.to_json - - parsed_body = nil - stub_request(:post, url).with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - ).to_return(status: 200, body: response_body) - - result = llm.generate(prompt, user: Discourse.system_user) - - # Verify that tool_choice is set to { type: "none" } - expect(parsed_body[:tool_choice]).to eq({ type: "none" }) - - # Verify that an assistant message with no_more_tool_calls_text was added - messages = parsed_body[:messages] - expect(messages.length).to eq(2) # user message + added assistant message - - last_message = messages.last - expect(last_message[:role]).to eq("assistant") - - expect(last_message[:content]).to eq( - DiscourseAi::Completions::Dialects::Dialect.no_more_tool_calls_text, - ) - - expect(result).to eq("I won't use any tools. Here's a direct response instead.") - end - end - - describe "forced tool use" do - it "can properly force tool use" do - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot", - messages: [type: :user, id: "user1", content: "echo hello"], - tools: [echo_tool], - tool_choice: "echo", - ) - - response_body = { - id: "msg_01RdJkxCbsEj9VFyFYAkfy2S", - type: "message", - role: "assistant", - model: "claude-3-haiku-20240307", - content: [ - { - type: "tool_use", - id: "toolu_bdrk_014CMjxtGmKUtGoEFPgc7PF7", - name: "echo", - input: { - text: "hello", - }, - }, - ], - stop_reason: "end_turn", - stop_sequence: nil, - usage: { - input_tokens: 345, - output_tokens: 65, - }, - }.to_json - - parsed_body = nil - stub_request(:post, url).with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - ).to_return(status: 200, body: response_body) - - llm.generate(prompt, user: Discourse.system_user) - - # Verify that tool_choice: "echo" is present - expect(parsed_body.dig(:tool_choice, :name)).to eq("echo") - end - end - - describe "structured output via prefilling" do - it "forces the response to be a JSON and using the given JSON schema" do - schema = { - type: "json_schema", - json_schema: { - name: "reply", - schema: { - type: "object", - properties: { - key: { - type: "string", - }, - }, - required: ["key"], - additionalProperties: false, - }, - strict: true, - }, - } - - body = (<<~STRING).strip - event: message_start - data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-opus-20240229", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - - event: content_block_start - data: {"type": "content_block_start", "index":0, "content_block": {"type": "text", "text": ""}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "\\""}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "key"}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "\\":\\""}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello!"}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "\\n there"}} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "\\"}"}} - - event: content_block_stop - data: {"type": "content_block_stop", "index": 0} - - event: message_delta - data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null, "usage":{"output_tokens": 15}}} - - event: message_stop - data: {"type": "message_stop"} - STRING - - parsed_body = nil - - stub_request(:post, url).with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "X-Api-Key" => "123", - "Anthropic-Version" => "2023-06-01", - }, - ).to_return(status: 200, body: body) - - structured_output = nil - llm.generate( - prompt, - user: Discourse.system_user, - feature_name: "testing", - response_format: schema, - ) { |partial, cancel| structured_output = partial } - - expect(structured_output.read_buffered_property(:key)).to eq("Hello!\n there") - - expected_body = { - model: "claude-3-opus-20240229", - max_tokens: 4096, - messages: [{ role: "user", content: "user1: hello" }, { role: "assistant", content: "{" }], - system: "You are hello bot", - stream: true, - } - expect(parsed_body).to eq(expected_body) - end - end -end diff --git a/spec/lib/completions/endpoints/aws_bedrock_spec.rb b/spec/lib/completions/endpoints/aws_bedrock_spec.rb deleted file mode 100644 index d75c8d5d..00000000 --- a/spec/lib/completions/endpoints/aws_bedrock_spec.rb +++ /dev/null @@ -1,760 +0,0 @@ -# frozen_string_literal: true - -require_relative "endpoint_compliance" -require "aws-eventstream" -require "aws-sigv4" - -class BedrockMock < EndpointMock -end - -RSpec.describe DiscourseAi::Completions::Endpoints::AwsBedrock do - subject(:endpoint) { described_class.new(model) } - - fab!(:user) - fab!(:model) { Fabricate(:bedrock_model) } - - let(:bedrock_mock) { BedrockMock.new(endpoint) } - - let(:compliance) do - EndpointsCompliance.new(self, endpoint, DiscourseAi::Completions::Dialects::Claude, user) - end - - def encode_message(message) - wrapped = { bytes: Base64.encode64(message.to_json) }.to_json - io = StringIO.new(wrapped) - aws_message = Aws::EventStream::Message.new(payload: io) - Aws::EventStream::Encoder.new.encode(aws_message) - end - - before { enable_current_plugin } - - it "should provide accurate max token count" do - prompt = DiscourseAi::Completions::Prompt.new("hello") - dialect = DiscourseAi::Completions::Dialects::Claude.new(prompt, model) - endpoint = DiscourseAi::Completions::Endpoints::AwsBedrock.new(model) - - model.name = "claude-2" - expect(endpoint.default_options(dialect)[:max_tokens]).to eq(4096) - - model.name = "claude-3-5-sonnet" - expect(endpoint.default_options(dialect)[:max_tokens]).to eq(8192) - - model.name = "claude-3-5-haiku" - options = endpoint.default_options(dialect) - expect(options[:max_tokens]).to eq(8192) - end - - describe "function calling" do - it "supports old school xml function calls" do - model.provider_params["disable_native_tools"] = true - model.save! - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - incomplete_tool_call = <<~XML.strip - <thinking>I should be ignored</thinking> - <search_quality_reflection>also ignored</search_quality_reflection> - <search_quality_score>0</search_quality_score> - <function_calls> - <invoke> - <tool_name>google</tool_name> - <parameters><query>sydney weather today</query></parameters> - </invoke> - </function_calls> - XML - - messages = - [ - { type: "message_start", message: { usage: { input_tokens: 9 } } }, - { type: "content_block_delta", delta: { text: "hello\n" } }, - { type: "content_block_delta", delta: { text: incomplete_tool_call } }, - { type: "message_delta", delta: { usage: { output_tokens: 25 } } }, - ].map { |message| encode_message(message) } - - request = nil - bedrock_mock.with_chunk_array_support do - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke-with-response-stream", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: messages) - - prompt = - DiscourseAi::Completions::Prompt.new( - messages: [{ type: :user, content: "what is the weather in sydney" }], - ) - - tool = { - name: "google", - description: "Will search using Google", - parameters: [ - { name: "query", description: "The search query", type: "string", required: true }, - ], - } - - prompt.tools = [tool] - response = [] - proxy.generate(prompt, user: user) { |partial| response << partial } - - expect(request.headers["Authorization"]).to be_present - expect(request.headers["X-Amz-Content-Sha256"]).to be_present - - parsed_body = JSON.parse(request.body) - expect(parsed_body["system"]).to include("<function_calls>") - expect(parsed_body["tools"]).to eq(nil) - expect(parsed_body["stop_sequences"]).to eq(["</function_calls>"]) - - expected = [ - "hello\n", - DiscourseAi::Completions::ToolCall.new( - id: "tool_0", - name: "google", - parameters: { - query: "sydney weather today", - }, - ), - ] - - expect(response).to eq(expected) - end - end - - it "supports streaming function calls" do - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - request = nil - - messages = - [ - { - type: "message_start", - message: { - id: "msg_bdrk_01WYxeNMk6EKn9s98r6XXrAB", - type: "message", - role: "assistant", - model: "claude-3-sonnet-20240307", - stop_sequence: nil, - usage: { - input_tokens: 840, - output_tokens: 1, - }, - content: [], - stop_reason: nil, - }, - }, - { - type: "content_block_start", - index: 0, - delta: { - text: "<thinking>I should be ignored</thinking>", - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "toolu_bdrk_014CMjxtGmKUtGoEFPgc7PF7", - name: "google", - input: { - }, - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: "", - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: "{\"query\": \"s", - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: "ydney weat", - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: "her today\"}", - }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "message_delta", - delta: { - stop_reason: "tool_use", - stop_sequence: nil, - }, - usage: { - output_tokens: 53, - }, - }, - { - type: "message_stop", - "amazon-bedrock-invocationMetrics": { - inputTokenCount: 846, - outputTokenCount: 39, - invocationLatency: 880, - firstByteLatency: 402, - }, - }, - ].map { |message| encode_message(message) } - - messages = messages.join("").split - - bedrock_mock.with_chunk_array_support do - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke-with-response-stream", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: messages) - - prompt = - DiscourseAi::Completions::Prompt.new( - messages: [{ type: :user, content: "what is the weather in sydney" }], - ) - - tool = { - name: "google", - description: "Will search using Google", - parameters: [ - { name: "query", description: "The search query", type: "string", required: true }, - ], - } - - prompt.tools = [tool] - response = [] - proxy.generate(prompt, user: user) { |partial| response << partial } - - expect(request.headers["Authorization"]).to be_present - expect(request.headers["X-Amz-Content-Sha256"]).to be_present - - expected_response = [ - DiscourseAi::Completions::ToolCall.new( - id: "toolu_bdrk_014CMjxtGmKUtGoEFPgc7PF7", - name: "google", - parameters: { - query: "sydney weather today", - }, - ), - ] - - expect(response).to eq(expected_response) - - expected = { - "max_tokens" => 4096, - "anthropic_version" => "bedrock-2023-05-31", - "messages" => [{ "role" => "user", "content" => "what is the weather in sydney" }], - "tools" => [ - { - "name" => "google", - "description" => "Will search using Google", - "input_schema" => { - "type" => "object", - "properties" => { - "query" => { - "type" => "string", - "description" => "The search query", - }, - }, - "required" => ["query"], - }, - }, - ], - } - expect(JSON.parse(request.body)).to eq(expected) - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(846) - expect(log.response_tokens).to eq(39) - end - end - end - - describe "Claude 3 support" do - it "supports regular completions" do - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - request = nil - - content = { - content: [text: "hello sam"], - usage: { - input_tokens: 10, - output_tokens: 20, - }, - }.to_json - - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: content) - - response = proxy.generate("hello world", user: user) - - expect(request.headers["Authorization"]).to be_present - expect(request.headers["X-Amz-Content-Sha256"]).to be_present - - expected = { - "max_tokens" => 4096, - "anthropic_version" => "bedrock-2023-05-31", - "messages" => [{ "role" => "user", "content" => "hello world" }], - "system" => "You are a helpful bot", - } - expect(JSON.parse(request.body)).to eq(expected) - - expect(response).to eq("hello sam") - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(10) - expect(log.response_tokens).to eq(20) - end - - it "supports thinking" do - model.provider_params["enable_reasoning"] = true - model.provider_params["reasoning_tokens"] = 10_000 - model.save! - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - request = nil - - content = { - content: [text: "hello sam"], - usage: { - input_tokens: 10, - output_tokens: 20, - }, - }.to_json - - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: content) - - response = proxy.generate("hello world", user: user) - - expect(request.headers["Authorization"]).to be_present - expect(request.headers["X-Amz-Content-Sha256"]).to be_present - - expected = { - "max_tokens" => 40_000, - "thinking" => { - "type" => "enabled", - "budget_tokens" => 10_000, - }, - "anthropic_version" => "bedrock-2023-05-31", - "messages" => [{ "role" => "user", "content" => "hello world" }], - "system" => "You are a helpful bot", - } - expect(JSON.parse(request.body)).to eq(expected) - - expect(response).to eq("hello sam") - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(10) - expect(log.response_tokens).to eq(20) - end - - it "supports claude 3 streaming" do - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - request = nil - - messages = - [ - { type: "message_start", message: { usage: { input_tokens: 9 } } }, - { type: "content_block_delta", delta: { text: "hello " } }, - { type: "content_block_delta", delta: { text: "sam" } }, - { type: "message_delta", delta: { usage: { output_tokens: 25 } } }, - ].map { |message| encode_message(message) } - - # stream 1 letter at a time - # cause we need to handle this case - messages = messages.join("").split - - bedrock_mock.with_chunk_array_support do - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke-with-response-stream", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: messages) - - response = +"" - proxy.generate("hello world", user: user) { |partial| response << partial } - - expect(request.headers["Authorization"]).to be_present - expect(request.headers["X-Amz-Content-Sha256"]).to be_present - - expected = { - "max_tokens" => 4096, - "anthropic_version" => "bedrock-2023-05-31", - "messages" => [{ "role" => "user", "content" => "hello world" }], - "system" => "You are a helpful bot", - } - expect(JSON.parse(request.body)).to eq(expected) - - expect(response).to eq("hello sam") - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(9) - expect(log.response_tokens).to eq(25) - end - end - end - - describe "parameter disabling" do - it "excludes disabled parameters from the request" do - model.update!( - provider_params: { - access_key_id: "123", - region: "us-east-1", - disable_top_p: true, - disable_temperature: true, - }, - ) - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - request = nil - - content = { - content: [text: "test response"], - usage: { - input_tokens: 10, - output_tokens: 5, - }, - }.to_json - - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: content) - - # Request with parameters that should be ignored - proxy.generate("test prompt", user: user, top_p: 0.9, temperature: 0.8, max_tokens: 500) - - # Parse the request body - request_body = JSON.parse(request.body) - - # Verify disabled parameters aren't included - expect(request_body).not_to have_key("top_p") - expect(request_body).not_to have_key("temperature") - - # Verify other parameters still work - expect(request_body).to have_key("max_tokens") - expect(request_body["max_tokens"]).to eq(500) - end - end - - describe "disabled tool use" do - it "handles tool_choice: :none by adding a prefill message instead of using tool_choice param" do - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - request = nil - - # Create a prompt with tool_choice: :none - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a helpful assistant", - messages: [{ type: :user, content: "don't use any tools please" }], - tools: [ - { - name: "echo", - description: "echo something", - parameters: [ - { name: "text", type: "string", description: "text to echo", required: true }, - ], - }, - ], - tool_choice: :none, - ) - - # Mock response from Bedrock - content = { - content: [text: "I won't use any tools. Here's a direct response instead."], - usage: { - input_tokens: 25, - output_tokens: 15, - }, - }.to_json - - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: content) - - proxy.generate(prompt, user: user) - - # Parse the request body - request_body = JSON.parse(request.body) - - # Verify that tool_choice is NOT present (not supported in Bedrock) - expect(request_body).not_to have_key("tool_choice") - - # Verify that an assistant message was added with no_more_tool_calls_text - messages = request_body["messages"] - expect(messages.length).to eq(2) # user message + added assistant message - - last_message = messages.last - expect(last_message["role"]).to eq("assistant") - - expect(last_message["content"]).to eq( - DiscourseAi::Completions::Dialects::Dialect.no_more_tool_calls_text, - ) - end - end - - describe "forced tool use" do - it "can properly force tool use" do - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - request = nil - - tools = [ - { - name: "echo", - description: "echo something", - parameters: [ - { name: "text", type: "string", description: "text to echo", required: true }, - ], - }, - ] - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot", - messages: [type: :user, id: "user1", content: "echo hello"], - tools: tools, - tool_choice: "echo", - ) - - # Mock response from Bedrock - content = { - content: [ - { - type: "tool_use", - id: "toolu_bdrk_014CMjxtGmKUtGoEFPgc7PF7", - name: "echo", - input: { - text: "hello", - }, - }, - ], - usage: { - input_tokens: 25, - output_tokens: 15, - }, - }.to_json - - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: content) - - proxy.generate(prompt, user: user) - - # Parse the request body - request_body = JSON.parse(request.body) - - # Verify that tool_choice: "echo" is present - expect(request_body.dig("tool_choice", "name")).to eq("echo") - end - end - - describe "structured output via prefilling" do - it "forces the response to be a JSON and using the given JSON schema" do - schema = { - type: "json_schema", - json_schema: { - name: "reply", - schema: { - type: "object", - properties: { - key: { - type: "string", - }, - }, - required: ["key"], - additionalProperties: false, - }, - strict: true, - }, - } - - messages = - [ - { type: "message_start", message: { usage: { input_tokens: 9 } } }, - { type: "content_block_delta", delta: { text: "\"" } }, - { type: "content_block_delta", delta: { text: "key" } }, - { type: "content_block_delta", delta: { text: "\":\"" } }, - { type: "content_block_delta", delta: { text: "Hello!" } }, - { type: "content_block_delta", delta: { text: "\n There" } }, - { type: "content_block_delta", delta: { text: "\"}" } }, - { type: "message_delta", delta: { usage: { output_tokens: 25 } } }, - ].map { |message| encode_message(message) } - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - request = nil - bedrock_mock.with_chunk_array_support do - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke-with-response-stream", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: messages) - - structured_output = nil - proxy.generate("hello world", response_format: schema, user: user) do |partial| - structured_output = partial - end - - expected = { - "max_tokens" => 4096, - "anthropic_version" => "bedrock-2023-05-31", - "messages" => [ - { "role" => "user", "content" => "hello world" }, - { "role" => "assistant", "content" => "{" }, - ], - "system" => "You are a helpful bot", - } - expect(JSON.parse(request.body)).to eq(expected) - - expect(structured_output.read_buffered_property(:key)).to eq("Hello!\n There") - end - end - - it "works with JSON schema array types" do - schema = { - type: "json_schema", - json_schema: { - name: "reply", - schema: { - type: "object", - properties: { - plain: { - type: "string", - }, - key: { - type: "array", - items: { - type: "string", - }, - }, - }, - required: %w[plain key], - additionalProperties: false, - }, - strict: true, - }, - } - - messages = - [ - { type: "message_start", message: { usage: { input_tokens: 9 } } }, - { type: "content_block_delta", delta: { text: "\"" } }, - { type: "content_block_delta", delta: { text: "key" } }, - { type: "content_block_delta", delta: { text: "\":" } }, - { type: "content_block_delta", delta: { text: " [\"" } }, - { type: "content_block_delta", delta: { text: "Hello!" } }, - { type: "content_block_delta", delta: { text: " I am" } }, - { type: "content_block_delta", delta: { text: " a " } }, - { type: "content_block_delta", delta: { text: "chunk\"," } }, - { type: "content_block_delta", delta: { text: "\"There" } }, - { type: "content_block_delta", delta: { text: "\"]," } }, - { type: "content_block_delta", delta: { text: " \"plain" } }, - { type: "content_block_delta", delta: { text: "\":\"" } }, - { type: "content_block_delta", delta: { text: "I'm here" } }, - { type: "content_block_delta", delta: { text: " too\"}" } }, - { type: "message_delta", delta: { usage: { output_tokens: 25 } } }, - ].map { |message| encode_message(message) } - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - request = nil - bedrock_mock.with_chunk_array_support do - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke-with-response-stream", - ) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: messages) - - structured_output = nil - proxy.generate("hello world", response_format: schema, user: user) do |partial| - structured_output = partial - end - - expected = { - "max_tokens" => 4096, - "anthropic_version" => "bedrock-2023-05-31", - "messages" => [ - { "role" => "user", "content" => "hello world" }, - { "role" => "assistant", "content" => "{" }, - ], - "system" => "You are a helpful bot", - } - expect(JSON.parse(request.body)).to eq(expected) - - expect(structured_output.read_buffered_property(:key)).to contain_exactly( - "Hello! I am a chunk", - "There", - ) - expect(structured_output.read_buffered_property(:plain)).to eq("I'm here too") - end - end - end -end diff --git a/spec/lib/completions/endpoints/cohere_spec.rb b/spec/lib/completions/endpoints/cohere_spec.rb deleted file mode 100644 index 43e8ec61..00000000 --- a/spec/lib/completions/endpoints/cohere_spec.rb +++ /dev/null @@ -1,374 +0,0 @@ -# frozen_string_literal: true -require_relative "endpoint_compliance" - -RSpec.describe DiscourseAi::Completions::Endpoints::Cohere do - fab!(:cohere_model) - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{cohere_model.id}") } - fab!(:user) - - let(:prompt) do - DiscourseAi::Completions::Prompt.new( - "You are hello bot", - messages: [ - { type: :user, id: "user1", content: "hello" }, - { type: :model, content: "hi user" }, - { type: :user, id: "user1", content: "thanks" }, - ], - ) - end - - let(:weather_tool) do - { - name: "weather", - description: "lookup weather in a city", - parameters: [{ name: "city", type: "string", description: "city name", required: true }], - } - end - - let(:prompt_with_tools) do - prompt = - DiscourseAi::Completions::Prompt.new( - "You are weather bot", - messages: [ - { type: :user, id: "user1", content: "what is the weather in sydney and melbourne?" }, - ], - ) - - prompt.tools = [weather_tool] - prompt - end - - let(:prompt_with_tool_results) do - prompt = - DiscourseAi::Completions::Prompt.new( - "You are weather bot", - messages: [ - { type: :user, id: "user1", content: "what is the weather in sydney and melbourne?" }, - { - type: :tool_call, - id: "tool_call_1", - name: "weather", - content: { arguments: [%w[city Sydney]] }.to_json, - }, - { type: :tool, id: "tool_call_1", name: "weather", content: { weather: "22c" }.to_json }, - ], - ) - - prompt.tools = [weather_tool] - prompt - end - - before { enable_current_plugin } - - it "is able to trigger a tool" do - body = (<<~TEXT).strip - {"is_finished":false,"event_type":"stream-start","generation_id":"1648206e-1fe4-4bb6-90cf-360dd55f575b"} -{"is_finished":false,"event_type":"tool-calls-generation","text":"I will search for 'who is sam saffron' and relay the information to the user.","tool_calls":[{"name":"google","parameters":{"query":"who is sam saffron"}}]} -{"is_finished":true,"event_type":"stream-end","response":{"response_id":"71d8c9e1-1138-4d70-80d1-10ddec41c989","text":"I will search for 'who is sam saffron' and relay the information to the user.","generation_id":"1648206e-1fe4-4bb6-90cf-360dd55f575b","chat_history":[{"role":"USER","message":"sam: who is sam saffron?"},{"role":"CHATBOT","message":"I will search for 'who is sam saffron' and relay the information to the user.","tool_calls":[{"name":"google","parameters":{"query":"who is sam saffron"}}]}],"finish_reason":"COMPLETE","meta":{"api_version":{"version":"1"},"billed_units":{"input_tokens":460,"output_tokens":27},"tokens":{"input_tokens":1227,"output_tokens":27}},"tool_calls":[{"name":"google","parameters":{"query":"who is sam saffron"}}]},"finish_reason":"COMPLETE"} - TEXT - - parsed_body = nil - result = [] - - sig = { - name: "google", - description: "Will search using Google", - parameters: [ - { name: "query", description: "The search query", type: "string", required: true }, - ], - } - - prompt.tools = [sig] - - EndpointMock.with_chunk_array_support do - stub_request(:post, "https://api.cohere.ai/v1/chat").with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "Authorization" => "Bearer ABC", - }, - ).to_return(status: 200, body: body.split("|")) - - llm.generate(prompt, user: user) { |partial, cancel| result << partial } - end - - text = "I will search for 'who is sam saffron' and relay the information to the user." - tool_call = - DiscourseAi::Completions::ToolCall.new( - id: "tool_0", - name: "google", - parameters: { - query: "who is sam saffron", - }, - ) - - expect(result).to eq([text, tool_call]) - - expected = { - model: "command-r-plus", - preamble: "You are hello bot", - chat_history: [ - { role: "USER", message: "user1: hello" }, - { role: "CHATBOT", message: "hi user" }, - ], - message: "user1: thanks", - tools: [ - { - name: "google", - description: "Will search using Google", - parameter_definitions: { - query: { - description: "The search query", - type: "str", - required: true, - }, - }, - }, - ], - force_single_step: false, - stream: true, - } - - expect(parsed_body).to eq(expected) - end - - it "is able to run tools" do - body = { - response_id: "0a90275b-273d-4690-abce-8018edcec7d0", - text: "Sydney is 22c", - generation_id: "cc2742f7-622c-4e42-8fd4-d95b21012e52", - chat_history: [], - finish_reason: "COMPLETE", - token_count: { - prompt_tokens: 29, - response_tokens: 11, - total_tokens: 40, - billed_tokens: 25, - }, - meta: { - api_version: { - version: "1", - }, - billed_units: { - input_tokens: 17, - output_tokens: 22, - }, - }, - }.to_json - - parsed_body = nil - stub_request(:post, "https://api.cohere.ai/v1/chat").with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "Authorization" => "Bearer ABC", - }, - ).to_return(status: 200, body: body) - - result = llm.generate(prompt_with_tool_results, user: user) - - expect(parsed_body[:preamble]).to include("You are weather bot") - - expect(result).to eq("Sydney is 22c") - audit = AiApiAuditLog.order("id desc").first - - # billing should be picked - expect(audit.request_tokens).to eq(17) - expect(audit.response_tokens).to eq(22) - - expect(audit.language_model).to eq("command-r-plus") - end - - it "is able to perform streaming completions" do - body = <<~TEXT - {"is_finished":false,"event_type":"stream-start","generation_id":"eb889b0f-c27d-45ea-98cf-567bdb7fc8bf"} - {"is_finished":false,"event_type":"text-generation","text":"You"} - {"is_finished":false,"event_type":"text-generation","text":"'re"} - {"is_finished":false,"event_type":"text-generation","text":" welcome"} - {"is_finished":false,"event_type":"text-generation","text":"!"} - {"is_finished":false,"event_type":"text-generation","text":" Is"} - {"is_finished":false,"event_type":"text-generation","text":" there"} - {"is_finished":false,"event_type":"text-generation","text":" anything"}| - {"is_finished":false,"event_type":"text-generation","text":" else"} - {"is_finished":false,"event_type":"text-generation","text":" I"} - {"is_finished":false,"event_type":"text-generation","text":" can"} - {"is_finished":false,"event_type":"text-generation","text":" help"}| - {"is_finished":false,"event_type":"text-generation","text":" you"} - {"is_finished":false,"event_type":"text-generation","text":" with"} - {"is_finished":false,"event_type":"text-generation","text":"?"}| - {"is_finished":true,"event_type":"stream-end","response":{"response_id":"d235db17-8555-493b-8d91-e601f76de3f9","text":"You're welcome! Is there anything else I can help you with?","generation_id":"eb889b0f-c27d-45ea-98cf-567bdb7fc8bf","chat_history":[{"role":"USER","message":"user1: hello"},{"role":"CHATBOT","message":"hi user"},{"role":"USER","message":"user1: thanks"},{"role":"CHATBOT","message":"You're welcome! Is there anything else I can help you with?"}],"token_count":{"prompt_tokens":29,"response_tokens":14,"total_tokens":43,"billed_tokens":28},"meta":{"api_version":{"version":"1"},"billed_units":{"input_tokens":14,"output_tokens":14}}},"finish_reason":"COMPLETE"} - TEXT - - parsed_body = nil - result = +"" - - EndpointMock.with_chunk_array_support do - stub_request(:post, "https://api.cohere.ai/v1/chat").with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "Authorization" => "Bearer ABC", - }, - ).to_return(status: 200, body: body.split("|")) - - result = llm.generate(prompt, user: user) { |partial, cancel| result << partial } - end - - expect(parsed_body[:preamble]).to eq("You are hello bot") - expect(parsed_body[:chat_history]).to eq( - [{ role: "USER", message: "user1: hello" }, { role: "CHATBOT", message: "hi user" }], - ) - expect(parsed_body[:message]).to eq("user1: thanks") - - expect(result).to eq("You're welcome! Is there anything else I can help you with?") - audit = AiApiAuditLog.order("id desc").first - - # billing should be picked - expect(audit.request_tokens).to eq(14) - expect(audit.response_tokens).to eq(14) - end - - it "is able to perform non streaming completions" do - body = { - response_id: "0a90275b-273d-4690-abce-8018edcec7d0", - text: "You're welcome! How can I help you today?", - generation_id: "cc2742f7-622c-4e42-8fd4-d95b21012e52", - chat_history: [ - { role: "USER", message: "user1: hello" }, - { role: "CHATBOT", message: "hi user" }, - { role: "USER", message: "user1: thanks" }, - { role: "CHATBOT", message: "You're welcome! How can I help you today?" }, - ], - finish_reason: "COMPLETE", - token_count: { - prompt_tokens: 29, - response_tokens: 11, - total_tokens: 40, - billed_tokens: 25, - }, - meta: { - api_version: { - version: "1", - }, - billed_units: { - input_tokens: 14, - output_tokens: 11, - }, - }, - }.to_json - - parsed_body = nil - stub_request(:post, "https://api.cohere.ai/v1/chat").with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "Authorization" => "Bearer ABC", - }, - ).to_return(status: 200, body: body) - - result = - llm.generate( - prompt, - user: user, - temperature: 0.1, - top_p: 0.5, - max_tokens: 100, - stop_sequences: ["stop"], - ) - - expect(parsed_body[:temperature]).to eq(0.1) - expect(parsed_body[:p]).to eq(0.5) - expect(parsed_body[:max_tokens]).to eq(100) - expect(parsed_body[:stop_sequences]).to eq(["stop"]) - - expect(parsed_body[:preamble]).to eq("You are hello bot") - expect(parsed_body[:chat_history]).to eq( - [{ role: "USER", message: "user1: hello" }, { role: "CHATBOT", message: "hi user" }], - ) - expect(parsed_body[:message]).to eq("user1: thanks") - - expect(result).to eq("You're welcome! How can I help you today?") - audit = AiApiAuditLog.order("id desc").first - - # billing should be picked - expect(audit.request_tokens).to eq(14) - expect(audit.response_tokens).to eq(11) - end - - it "is able to return structured outputs" do - schema = { - type: "json_schema", - json_schema: { - name: "reply", - schema: { - type: "object", - properties: { - key: { - type: "string", - }, - }, - required: ["key"], - additionalProperties: false, - }, - strict: true, - }, - } - - body = <<~TEXT - {"is_finished":false,"event_type":"stream-start","generation_id":"eb889b0f-c27d-45ea-98cf-567bdb7fc8bf"} - {"is_finished":false,"event_type":"text-generation","text":"{\\""} - {"is_finished":false,"event_type":"text-generation","text":"key"} - {"is_finished":false,"event_type":"text-generation","text":"\\":\\""} - {"is_finished":false,"event_type":"text-generation","text":"Hello!"} - {"is_finished":false,"event_type":"text-generation","text":"\\n there"} - {"is_finished":false,"event_type":"text-generation","text":"\\"}"}| - {"is_finished":true,"event_type":"stream-end","response":{"response_id":"d235db17-8555-493b-8d91-e601f76de3f9","text":"{\\"key\\":\\"Hello! \\n there\\"}","generation_id":"eb889b0f-c27d-45ea-98cf-567bdb7fc8bf","chat_history":[{"role":"USER","message":"user1: hello"},{"role":"CHATBOT","message":"hi user"},{"role":"USER","message":"user1: thanks"},{"role":"CHATBOT","message":"You're welcome! Is there anything else I can help you with?"}],"token_count":{"prompt_tokens":29,"response_tokens":14,"total_tokens":43,"billed_tokens":28},"meta":{"api_version":{"version":"1"},"billed_units":{"input_tokens":14,"output_tokens":14}}},"finish_reason":"COMPLETE"} - TEXT - - parsed_body = nil - structured_output = nil - - EndpointMock.with_chunk_array_support do - stub_request(:post, "https://api.cohere.ai/v1/chat").with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - headers: { - "Content-Type" => "application/json", - "Authorization" => "Bearer ABC", - }, - ).to_return(status: 200, body: body.split("|")) - - result = - llm.generate(prompt, response_format: schema, user: user) do |partial, cancel| - structured_output = partial - end - end - - expect(parsed_body[:preamble]).to eq("You are hello bot") - expect(parsed_body[:chat_history]).to eq( - [{ role: "USER", message: "user1: hello" }, { role: "CHATBOT", message: "hi user" }], - ) - expect(parsed_body[:message]).to eq("user1: thanks") - - expect(structured_output.read_buffered_property(:key)).to eq("Hello!\n there") - end -end diff --git a/spec/lib/completions/endpoints/endpoint_compliance.rb b/spec/lib/completions/endpoints/endpoint_compliance.rb deleted file mode 100644 index 8ad95b95..00000000 --- a/spec/lib/completions/endpoints/endpoint_compliance.rb +++ /dev/null @@ -1,232 +0,0 @@ -# frozen_string_literal: true - -require "net/http" - -class EndpointMock - def initialize(model) - @model = model - end - - attr_reader :model - - def stub_simple_call(prompt) - stub_response(prompt, simple_response) - end - - def stub_tool_call(prompt) - stub_response(prompt, tool_response, tool_call: true) - end - - def stub_streamed_simple_call(prompt) - with_chunk_array_support do - stub_streamed_response(prompt, streamed_simple_deltas) - yield - end - end - - def stub_streamed_tool_call(prompt) - with_chunk_array_support do - stub_streamed_response(prompt, tool_deltas, tool_call: true) - yield - end - end - - def simple_response - "1. Serenity\\n2. Laughter\\n3. Adventure" - end - - def streamed_simple_deltas - ["Mount", "ain", " ", "Tree ", "Frog"] - end - - def tool_deltas - ["<function", <<~REPLY.strip, <<~REPLY.strip, <<~REPLY.strip] - _calls> - <invoke> - <tool_name>get_weather</tool_name> - <parameters> - <location>Sydney</location> - <unit>c</unit> - </para - REPLY - meters> - </invoke> - </funct - REPLY - ion_calls> - REPLY - end - - def tool_response - tool_deltas.join - end - - def invocation_response - DiscourseAi::Completions::ToolCall.new( - id: "tool_0", - name: "get_weather", - parameters: { - location: "Sydney", - unit: "c", - }, - ) - end - - def tool_id - "get_weather" - end - - def tool - { - name: "get_weather", - description: "Get the weather in a city", - parameters: [ - { name: "location", type: "string", description: "the city name", required: true }, - { - name: "unit", - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - required: true, - }, - ], - } - end - - def with_chunk_array_support - mock = mocked_http - @original_net_http = ::FinalDestination.send(:remove_const, :HTTP) - ::FinalDestination.send(:const_set, :HTTP, mock) - - yield - ensure - ::FinalDestination.send(:remove_const, :HTTP) - ::FinalDestination.send(:const_set, :HTTP, @original_net_http) - end - - def self.with_chunk_array_support(&blk) - self.new(nil).with_chunk_array_support(&blk) - end - - protected - - # Copied from https://github.com/bblimke/webmock/issues/629 - # Workaround for stubbing a streamed response - def mocked_http - Class.new(FinalDestination::HTTP) do - def request(*) - super do |response| - response.instance_eval do - def read_body(*, &block) - if block_given? - @body.each(&block) - else - super - end - end - end - - yield response if block_given? - - response - end - end - end - end -end - -class EndpointsCompliance - def initialize(rspec, endpoint, dialect_klass, user) - @rspec = rspec - @endpoint = endpoint - @dialect_klass = dialect_klass - @user = user - end - - delegate :expect, :eq, :be_present, to: :rspec - - def generic_prompt(tools: []) - DiscourseAi::Completions::Prompt.new( - "You write words", - messages: [{ type: :user, content: "write 3 words" }], - tools: tools, - ) - end - - def dialect(prompt: generic_prompt) - dialect_klass.new(prompt, endpoint.llm_model) - end - - def regular_mode_simple_prompt(mock) - mock.stub_simple_call(dialect.translate) - - completion_response = endpoint.perform_completion!(dialect, user) - - expect(completion_response).to eq(mock.simple_response) - - expect(AiApiAuditLog.count).to eq(1) - log = AiApiAuditLog.first - - expect(log.provider_id).to eq(endpoint.provider_id) - expect(log.user_id).to eq(user.id) - expect(log.raw_request_payload).to be_present - expect(log.raw_response_payload).to eq(mock.response(completion_response).to_json) - expect(log.request_tokens).to eq(endpoint.prompt_size(dialect.translate)) - expect(log.response_tokens).to eq(endpoint.llm_model.tokenizer_class.size(completion_response)) - end - - def regular_mode_tools(mock) - prompt = generic_prompt(tools: [mock.tool]) - a_dialect = dialect(prompt: prompt) - mock.stub_tool_call(a_dialect.translate) - - completion_response = endpoint.perform_completion!(a_dialect, user) - expect(completion_response).to eq(mock.invocation_response) - end - - def streaming_mode_simple_prompt(mock) - mock.stub_streamed_simple_call(dialect.translate) do - completion_response = +"" - - cancel_manager = DiscourseAi::Completions::CancelManager.new - - endpoint.perform_completion!(dialect, user, cancel_manager: cancel_manager) do |partial| - completion_response << partial - cancel_manager.cancel! if completion_response.split(" ").length == 2 - end - - expect(AiApiAuditLog.count).to eq(1) - log = AiApiAuditLog.first - - expect(log.provider_id).to eq(endpoint.provider_id) - expect(log.user_id).to eq(user.id) - expect(log.raw_request_payload).to be_present - expect(log.raw_response_payload).to be_present - expect(log.request_tokens).to eq(endpoint.prompt_size(dialect.translate)) - - expect(log.response_tokens).to eq( - endpoint.llm_model.tokenizer_class.size(mock.streamed_simple_deltas[0...-1].join), - ) - end - end - - def streaming_mode_tools(mock) - prompt = generic_prompt(tools: [mock.tool]) - a_dialect = dialect(prompt: prompt) - - cancel_manager = DiscourseAi::Completions::CancelManager.new - - mock.stub_streamed_tool_call(a_dialect.translate) do - buffered_partial = [] - - endpoint.perform_completion!(a_dialect, user, cancel_manager: cancel_manager) do |partial| - buffered_partial << partial - cancel_manager if partial.is_a?(DiscourseAi::Completions::ToolCall) - end - - expect(buffered_partial).to eq([mock.invocation_response]) - end - end - - attr_reader :rspec, :endpoint, :dialect_klass, :user -end diff --git a/spec/lib/completions/endpoints/gemini_spec.rb b/spec/lib/completions/endpoints/gemini_spec.rb deleted file mode 100644 index 2496acb5..00000000 --- a/spec/lib/completions/endpoints/gemini_spec.rb +++ /dev/null @@ -1,675 +0,0 @@ -# frozen_string_literal: true - -require_relative "endpoint_compliance" - -class GeminiMock < EndpointMock - def response(content, tool_call: false) - { - candidates: [ - { - content: { - parts: [(tool_call ? content : { text: content })], - role: "model", - }, - finishReason: "STOP", - index: 0, - safetyRatings: [ - { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_HATE_SPEECH", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_HARASSMENT", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_DANGEROUS_CONTENT", probability: "NEGLIGIBLE" }, - ], - }, - ], - promptFeedback: { - safetyRatings: [ - { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_HATE_SPEECH", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_HARASSMENT", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_DANGEROUS_CONTENT", probability: "NEGLIGIBLE" }, - ], - }, - } - end - - def stub_response(prompt, response_text, tool_call: false) - WebMock - .stub_request( - :post, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=#{SiteSetting.ai_gemini_api_key}", - ) - .with(body: request_body(prompt, tool_call)) - .to_return(status: 200, body: JSON.dump(response(response_text, tool_call: tool_call))) - end - - def stream_line(delta, finish_reason: nil, tool_call: false) - { - candidates: [ - { - content: { - parts: [(tool_call ? delta : { text: delta })], - role: "model", - }, - finishReason: finish_reason, - index: 0, - safetyRatings: [ - { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_HATE_SPEECH", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_HARASSMENT", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_DANGEROUS_CONTENT", probability: "NEGLIGIBLE" }, - ], - }, - ], - }.to_json - end - - def stub_streamed_response(prompt, deltas, tool_call: false) - chunks = - deltas.each_with_index.map do |_, index| - if index == (deltas.length - 1) - stream_line(deltas[index], finish_reason: "STOP", tool_call: tool_call) - else - stream_line(deltas[index], tool_call: tool_call) - end - end - - chunks = chunks.join("\n,\n").prepend("[\n").concat("\n]").split("") - - WebMock - .stub_request( - :post, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:streamGenerateContent?key=#{SiteSetting.ai_gemini_api_key}", - ) - .with(body: request_body(prompt, tool_call)) - .to_return(status: 200, body: chunks) - end - - def tool_payload - { - name: "get_weather", - description: "Get the weather in a city", - parameters: { - type: "object", - required: %w[location unit], - properties: { - "location" => { - type: "string", - description: "the city name", - }, - "unit" => { - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - }, - }, - }, - } - end - - def request_body(prompt, tool_call) - model - .default_options - .merge(contents: prompt) - .tap { |b| b[:tools] = [{ function_declarations: [tool_payload] }] if tool_call } - .to_json - end - - def tool_deltas - [ - { "functionCall" => { name: "get_weather", args: {} } }, - { "functionCall" => { name: "get_weather", args: { location: "" } } }, - { "functionCall" => { name: "get_weather", args: { location: "Sydney", unit: "c" } } }, - ] - end - - def tool_response - { "functionCall" => { name: "get_weather", args: { location: "Sydney", unit: "c" } } } - end -end - -RSpec.describe DiscourseAi::Completions::Endpoints::Gemini do - subject(:endpoint) { described_class.new(model) } - - fab!(:model) { Fabricate(:gemini_model, vision_enabled: true) } - - fab!(:user) - - let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } - let(:upload100x100) do - UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) - end - - let(:gemini_mock) { GeminiMock.new(endpoint) } - - let(:compliance) do - EndpointsCompliance.new(self, endpoint, DiscourseAi::Completions::Dialects::Gemini, user) - end - - let(:echo_tool) do - { - name: "echo", - description: "echo something", - parameters: [{ name: "text", type: "string", description: "text to echo", required: true }], - } - end - - before { enable_current_plugin } - - it "correctly configures thinking when enabled" do - model.update!(provider_params: { enable_thinking: "true", thinking_tokens: "10000" }) - - response = gemini_mock.response("Using thinking mode").to_json - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - true - end, - ).to_return(status: 200, body: response) - - response = llm.generate("Hello", user: user) - - parsed = JSON.parse(req_body, symbolize_names: true) - - # Verify thinking config is properly set with the token limit - expect(parsed.dig(:generationConfig, :thinkingConfig)).to eq({ thinkingBudget: 10_000 }) - end - - it "correctly handles max output tokens" do - model.update!(max_output_tokens: 1000) - - response = gemini_mock.response("some response mode").to_json - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - true - end, - ).to_return(status: 200, body: response) - - response = llm.generate("Hello", user: user, max_tokens: 10_000) - parsed = JSON.parse(req_body, symbolize_names: true) - - expect(parsed.dig(:generationConfig, :maxOutputTokens)).to eq(1000) - - response = llm.generate("Hello", user: user, max_tokens: 50) - parsed = JSON.parse(req_body, symbolize_names: true) - - expect(parsed.dig(:generationConfig, :maxOutputTokens)).to eq(50) - - response = llm.generate("Hello", user: user) - parsed = JSON.parse(req_body, symbolize_names: true) - - expect(parsed.dig(:generationConfig, :maxOutputTokens)).to eq(1000) - end - - it "clamps thinking tokens within allowed limits" do - model.update!(provider_params: { enable_thinking: "true", thinking_tokens: "30000" }) - - response = gemini_mock.response("Thinking tokens clamped").to_json - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - true - end, - ).to_return(status: 200, body: response) - - response = llm.generate("Hello", user: user) - - parsed = JSON.parse(req_body, symbolize_names: true) - - # Verify thinking tokens are clamped to 24_576 - expect(parsed.dig(:generationConfig, :thinkingConfig)).to eq({ thinkingBudget: 24_576 }) - end - - it "does not add thinking config when disabled" do - model.update!(provider_params: { enable_thinking: false, thinking_tokens: "10000" }) - - response = gemini_mock.response("No thinking mode").to_json - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - true - end, - ).to_return(status: 200, body: response) - - response = llm.generate("Hello", user: user) - - parsed = JSON.parse(req_body, symbolize_names: true) - - # Verify thinking config is not present - expect(parsed.dig(:generationConfig, :thinkingConfig)).to be_nil - end - - # by default gemini is meant to use AUTO mode, however new experimental models - # appear to require this to be explicitly set - it "Explicitly specifies tool config" do - prompt = DiscourseAi::Completions::Prompt.new("Hello", tools: [echo_tool]) - - response = gemini_mock.response("World").to_json - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - true - end, - ).to_return(status: 200, body: response) - - response = llm.generate(prompt, user: user) - - expect(response).to eq("World") - - parsed = JSON.parse(req_body, symbolize_names: true) - - expect(parsed[:tool_config]).to eq({ function_calling_config: { mode: "AUTO" } }) - end - - it "properly encodes tool calls" do - prompt = DiscourseAi::Completions::Prompt.new("Hello", tools: [echo_tool]) - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - response_json = { "functionCall" => { name: "echo", args: { text: "<S>ydney" } } } - response = gemini_mock.response(response_json, tool_call: true).to_json - - stub_request(:post, url).to_return(status: 200, body: response) - - response = llm.generate(prompt, user: user) - - tool = - DiscourseAi::Completions::ToolCall.new( - id: "tool_0", - name: "echo", - parameters: { - text: "<S>ydney", - }, - ) - - expect(response).to eq(tool) - end - - it "Supports Vision API" do - prompt = - DiscourseAi::Completions::Prompt.new( - "You are image bot", - messages: [type: :user, id: "user1", content: ["hello", { upload_id: upload100x100.id }]], - ) - - encoded = prompt.encoded_uploads(prompt.messages.last) - - response = gemini_mock.response("World").to_json - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - true - end, - ).to_return(status: 200, body: response) - - response = llm.generate(prompt, user: user) - - expect(response).to eq("World") - - expected_prompt = { - "generationConfig" => { - }, - "safetySettings" => [ - { "category" => "HARM_CATEGORY_HARASSMENT", "threshold" => "BLOCK_NONE" }, - { "category" => "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold" => "BLOCK_NONE" }, - { "category" => "HARM_CATEGORY_HATE_SPEECH", "threshold" => "BLOCK_NONE" }, - { "category" => "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold" => "BLOCK_NONE" }, - ], - "contents" => [ - { - "role" => "user", - "parts" => [ - { "text" => "user1: hello" }, - { "inlineData" => { "mimeType" => "image/jpeg", "data" => encoded[0][:base64] } }, - ], - }, - ], - "systemInstruction" => { - "role" => "system", - "parts" => [{ "text" => "You are image bot" }], - }, - } - - expect(JSON.parse(req_body)).to eq(expected_prompt) - end - - it "Can stream tool calls correctly" do - rows = [ - { - candidates: [ - { - content: { - parts: [{ functionCall: { name: "echo", args: { text: "sam<>wh!s" } } }], - role: "model", - }, - safetyRatings: [ - { category: "HARM_CATEGORY_HATE_SPEECH", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_DANGEROUS_CONTENT", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_HARASSMENT", probability: "NEGLIGIBLE" }, - { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", probability: "NEGLIGIBLE" }, - ], - }, - ], - usageMetadata: { - promptTokenCount: 625, - totalTokenCount: 625, - }, - modelVersion: "gemini-1.5-pro-002", - }, - { - candidates: [{ content: { parts: [{ text: "" }], role: "model" }, finishReason: "STOP" }], - usageMetadata: { - promptTokenCount: 625, - candidatesTokenCount: 4, - totalTokenCount: 629, - }, - modelVersion: "gemini-1.5-pro-002", - }, - ] - - payload = rows.map { |r| "data: #{r.to_json}\n\n" }.join - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:streamGenerateContent?alt=sse&key=123" - - prompt = DiscourseAi::Completions::Prompt.new("Hello", tools: [echo_tool]) - - output = [] - - stub_request(:post, url).to_return(status: 200, body: payload) - llm.generate(prompt, user: user) { |partial| output << partial } - - tool_call = - DiscourseAi::Completions::ToolCall.new( - id: "tool_0", - name: "echo", - parameters: { - text: "sam<>wh!s", - }, - ) - - expect(output).to eq([tool_call]) - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(625) - expect(log.response_tokens).to eq(4) - end - - it "Can correctly handle malformed responses" do - response = <<~TEXT - data: {"candidates": [{"content": {"parts": [{"text": "Certainly"}],"role": "model"}}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "! I'll create a simple \\"Hello, World!\\" page where each letter"}],"role": "model"},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"}]}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": " has a different color using inline styles for simplicity. Each letter will be wrapped"}],"role": "model"},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"}]}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": ""}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"finishReason": "MALFORMED_FUNCTION_CALL"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - TEXT - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:streamGenerateContent?alt=sse&key=123" - - output = [] - - stub_request(:post, url).to_return(status: 200, body: response) - llm.generate("Hello", user: user) { |partial| output << partial } - - expect(output).to eq( - [ - "Certainly", - "! I'll create a simple \"Hello, World!\" page where each letter", - " has a different color using inline styles for simplicity. Each letter will be wrapped", - ], - ) - end - - it "Can correctly handle streamed responses even if they are chunked badly" do - data = +"" - data << "da|ta: |" - data << gemini_mock.response("Hello").to_json - data << "\r\n\r\ndata: " - data << gemini_mock.response(" |World").to_json - data << "\r\n\r\ndata: " - data << gemini_mock.response(" Sam").to_json - - split = data.split("|") - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:streamGenerateContent?alt=sse&key=123" - - output = [] - gemini_mock.with_chunk_array_support do - stub_request(:post, url).to_return(status: 200, body: split) - llm.generate("Hello", user: user) { |partial| output << partial } - end - - expect(output.join).to eq("Hello World Sam") - end - - it "can properly disable tool use with :none" do - prompt = DiscourseAi::Completions::Prompt.new("Hello", tools: [echo_tool], tool_choice: :none) - - response = gemini_mock.response("I won't use any tools").to_json - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - true - end, - ).to_return(status: 200, body: response) - - response = llm.generate(prompt, user: user) - - expect(response).to eq("I won't use any tools") - - parsed = JSON.parse(req_body, symbolize_names: true) - - # Verify that function_calling_config mode is set to "NONE" - expect(parsed[:tool_config]).to eq({ function_calling_config: { mode: "NONE" } }) - end - - it "can properly force specific tool use" do - prompt = DiscourseAi::Completions::Prompt.new("Hello", tools: [echo_tool], tool_choice: "echo") - - response = gemini_mock.response("World").to_json - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:generateContent?key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - _req_body - end, - ).to_return(status: 200, body: response) - - response = llm.generate(prompt, user: user) - - parsed = JSON.parse(req_body, symbolize_names: true) - - # Verify that function_calling_config is correctly set to ANY mode with the specified tool - expect(parsed[:tool_config]).to eq( - { function_calling_config: { mode: "ANY", allowed_function_names: ["echo"] } }, - ) - end - - describe "structured output via JSON Schema" do - it "forces the response to be a JSON" do - schema = { - type: "json_schema", - json_schema: { - name: "reply", - schema: { - type: "object", - properties: { - key: { - type: "string", - }, - num: { - type: "integer", - }, - }, - required: ["key"], - additionalProperties: false, - }, - strict: true, - }, - } - - response = <<~TEXT.strip - data: {"candidates": [{"content": {"parts": [{"text": "{\\""}],"role": "model"}}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "key"}],"role": "model"},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"}]}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "\\":\\""}],"role": "model"},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"}]}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "Hello!"}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "\\n "}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "there"}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "\\","}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "\\""}],"role": "model"}}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "num"}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "\\":"}],"role": "model"},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"}]}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "42"}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "}"}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"finishReason": "MALFORMED_FUNCTION_CALL"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - TEXT - - req_body = nil - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:streamGenerateContent?alt=sse&key=123" - - stub_request(:post, url).with( - body: - proc do |_req_body| - req_body = _req_body - true - end, - ).to_return(status: 200, body: response) - - structured_response = nil - - llm.generate("Hello", response_format: schema, user: user) do |partial| - structured_response = partial - end - - expect(structured_response.read_buffered_property(:key)).to eq("Hello!\n there") - expect(structured_response.read_buffered_property(:num)).to eq(42) - - parsed = JSON.parse(req_body, symbolize_names: true) - - # Verify that schema is passed following Gemini API specs. - expect(parsed.dig(:generationConfig, :responseSchema)).to eq( - schema.dig(:json_schema, :schema).except(:additionalProperties), - ) - expect(parsed.dig(:generationConfig, :responseMimeType)).to eq("application/json") - - structured_response = nil - # once more but this time lets have the schema as string keys - llm.generate("Hello", response_format: schema.as_json, user: user) do |partial| - structured_response = partial - end - - expect(structured_response.read_buffered_property(:key)).to eq("Hello!\n there") - expect(structured_response.read_buffered_property(:num)).to eq(42) - - parsed = JSON.parse(req_body, symbolize_names: true) - - # Verify that schema is passed following Gemini API specs. - expect(parsed.dig(:generationConfig, :responseSchema)).to eq( - schema.dig(:json_schema, :schema).except(:additionalProperties), - ) - expect(parsed.dig(:generationConfig, :responseMimeType)).to eq("application/json") - end - end - - it "includes model params in the request" do - response = <<~TEXT - data: {"candidates": [{"content": {"parts": [{"text": "Hello"}],"role": "model"}}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": "! This is a simple response"}],"role": "model"},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"}]}],"usageMetadata": {"promptTokenCount": 399,"totalTokenCount": 399},"modelVersion": "gemini-1.5-pro-002"} - - data: {"candidates": [{"content": {"parts": [{"text": ""}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 399,"candidatesTokenCount": 191,"totalTokenCount": 590},"modelVersion": "gemini-1.5-pro-002"} - - TEXT - - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - url = "#{model.url}:streamGenerateContent?alt=sse&key=123" - - output = [] - - stub_request(:post, url).with( - body: hash_including(generationConfig: { temperature: 0.2 }), - ).to_return(status: 200, body: response) - - llm.generate("Hello", user: user, temperature: 0.2) { |partial| output << partial } - - expect(output).to eq(["Hello", "! This is a simple response"]) - end -end diff --git a/spec/lib/completions/endpoints/hugging_face_spec.rb b/spec/lib/completions/endpoints/hugging_face_spec.rb deleted file mode 100644 index 56ade1aa..00000000 --- a/spec/lib/completions/endpoints/hugging_face_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -require_relative "endpoint_compliance" - -class HuggingFaceMock < EndpointMock - def response(content) - { - id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S", - object: "chat.completion", - created: 1_678_464_820, - model: "Llama2-*-chat-hf", - usage: { - prompt_tokens: 337, - completion_tokens: 162, - total_tokens: 499, - }, - choices: [ - { message: { role: "assistant", content: content }, finish_reason: "stop", index: 0 }, - ], - } - end - - def stub_response(prompt, response_text, tool_call: false) - WebMock - .stub_request(:post, "https://test.dev/v1/chat/completions") - .with(body: request_body(prompt)) - .to_return(status: 200, body: JSON.dump(response(response_text))) - end - - def stream_line(delta, finish_reason: nil) - +"data: " << { - id: "chatcmpl-#{SecureRandom.hex}", - object: "chat.completion.chunk", - created: 1_681_283_881, - model: "Llama2-*-chat-hf", - choices: [{ delta: { content: delta } }], - finish_reason: finish_reason, - index: 0, - }.to_json - end - - def stub_raw(chunks) - WebMock.stub_request(:post, "https://test.dev/v1/chat/completions").to_return( - status: 200, - body: chunks, - ) - end - - def stub_streamed_response(prompt, deltas, tool_call: false) - chunks = - deltas.each_with_index.map do |_, index| - if index == (deltas.length - 1) - stream_line(deltas[index], finish_reason: "stop_sequence") - else - stream_line(deltas[index]) - end - end - - chunks = (chunks.join("\n\n") << "data: [DONE]").split("") - - WebMock - .stub_request(:post, "https://test.dev/v1/chat/completions") - .with(body: request_body(prompt, stream: true)) - .to_return(status: 200, body: chunks) - - yield if block_given? - end - - def request_body(prompt, stream: false, tool_call: false) - model - .default_options - .merge(messages: prompt) - .tap do |b| - b[:max_tokens] = 63_991 - b[:stream] = true if stream - end - .to_json - end -end - -RSpec.describe DiscourseAi::Completions::Endpoints::HuggingFace do - subject(:endpoint) { described_class.new(hf_model) } - - fab!(:hf_model) - fab!(:user) - - let(:hf_mock) { HuggingFaceMock.new(endpoint) } - - let(:compliance) do - EndpointsCompliance.new( - self, - endpoint, - DiscourseAi::Completions::Dialects::OpenAiCompatible, - user, - ) - end - - before { enable_current_plugin } - - describe "#perform_completion!" do - context "when using regular mode" do - context "with simple prompts" do - it "completes a trivial prompt and logs the response" do - compliance.regular_mode_simple_prompt(hf_mock) - end - end - end - - describe "when using streaming mode" do - context "with simple prompts" do - it "completes a trivial prompt and logs the response" do - compliance.streaming_mode_simple_prompt(hf_mock) - end - end - end - end -end diff --git a/spec/lib/completions/endpoints/nova_spec.rb b/spec/lib/completions/endpoints/nova_spec.rb deleted file mode 100644 index 751ccf3c..00000000 --- a/spec/lib/completions/endpoints/nova_spec.rb +++ /dev/null @@ -1,288 +0,0 @@ -# frozen_string_literal: true - -require_relative "endpoint_compliance" -require "aws-eventstream" -require "aws-sigv4" - -class BedrockMock < EndpointMock -end - -# nova is all implemented in bedrock endpoint, split out here -RSpec.describe DiscourseAi::Completions::Endpoints::AwsBedrock do - fab!(:user) - fab!(:nova_model) - - subject(:endpoint) { described_class.new(nova_model) } - - let(:bedrock_mock) { BedrockMock.new(endpoint) } - - let(:stream_url) do - "https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.nova-pro-v1:0/invoke-with-response-stream" - end - - def encode_message(message) - wrapped = { bytes: Base64.encode64(message.to_json) }.to_json - io = StringIO.new(wrapped) - aws_message = Aws::EventStream::Message.new(payload: io) - Aws::EventStream::Encoder.new.encode(aws_message) - end - - before { enable_current_plugin } - - it "should be able to make a simple request" do - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{nova_model.id}") - - content = { - "output" => { - "message" => { - "content" => [{ "text" => "it is 2." }], - "role" => "assistant", - }, - }, - "stopReason" => "end_turn", - "usage" => { - "inputTokens" => 14, - "outputTokens" => 119, - "totalTokens" => 133, - "cacheReadInputTokenCount" => nil, - "cacheWriteInputTokenCount" => nil, - }, - }.to_json - - stub_request( - :post, - "https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.nova-pro-v1:0/invoke", - ).to_return(status: 200, body: content) - - response = proxy.generate("hello world", user: user) - expect(response).to eq("it is 2.") - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(14) - expect(log.response_tokens).to eq(119) - end - - it "should be able to make a streaming request" do - messages = - [ - { messageStart: { role: "assistant" } }, - { contentBlockDelta: { delta: { text: "Hello" }, contentBlockIndex: 0 } }, - { contentBlockStop: { contentBlockIndex: 0 } }, - { contentBlockDelta: { delta: { text: "!" }, contentBlockIndex: 1 } }, - { contentBlockStop: { contentBlockIndex: 1 } }, - { - metadata: { - usage: { - inputTokens: 14, - outputTokens: 18, - }, - metrics: { - }, - trace: { - }, - }, - "amazon-bedrock-invocationMetrics": { - inputTokenCount: 14, - outputTokenCount: 18, - invocationLatency: 402, - firstByteLatency: 72, - }, - }, - ].map { |message| encode_message(message) } - - stub_request(:post, stream_url).to_return(status: 200, body: messages.join) - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{nova_model.id}") - responses = [] - proxy.generate("Hello!", user: user) { |partial| responses << partial } - - expect(responses).to eq(%w[Hello !]) - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(14) - expect(log.response_tokens).to eq(18) - end - - it "should support native streaming tool calls" do - #model.provider_params["disable_native_tools"] = true - #model.save! - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{nova_model.id}") - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a helpful assistant.", - messages: [{ type: :user, content: "what is the time in EST" }], - ) - - tool = { - name: "time", - description: "Will look up the current time", - parameters: [ - { name: "timezone", description: "The timezone", type: "string", required: true }, - ], - } - - prompt.tools = [tool] - - messages = - [ - { messageStart: { role: "assistant" } }, - { - contentBlockStart: { - start: { - toolUse: { - toolUseId: "e1bd7033-7244-4408-b088-1d33cbcf0b67", - name: "time", - }, - }, - contentBlockIndex: 0, - }, - }, - { - contentBlockDelta: { - delta: { - toolUse: { - input: "{\"timezone\":\"EST\"}", - }, - }, - contentBlockIndex: 0, - }, - }, - { contentBlockStop: { contentBlockIndex: 0 } }, - { messageStop: { stopReason: "end_turn" } }, - { - metadata: { - usage: { - inputTokens: 481, - outputTokens: 28, - }, - metrics: { - }, - trace: { - }, - }, - "amazon-bedrock-invocationMetrics": { - inputTokenCount: 481, - outputTokenCount: 28, - invocationLatency: 383, - firstByteLatency: 57, - }, - }, - ].map { |message| encode_message(message) } - - request = nil - stub_request(:post, stream_url) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: messages) - - response = [] - bedrock_mock.with_chunk_array_support do - proxy.generate(prompt, user: user, max_tokens: 200) { |partial| response << partial } - end - - parsed_request = JSON.parse(request.body) - expected = { - "system" => [{ "text" => "You are a helpful assistant." }], - "messages" => [{ "role" => "user", "content" => [{ "text" => "what is the time in EST" }] }], - "inferenceConfig" => { - "max_new_tokens" => 200, - }, - "toolConfig" => { - "tools" => [ - { - "toolSpec" => { - "name" => "time", - "description" => "Will look up the current time", - "inputSchema" => { - "json" => { - "type" => "object", - "required" => ["timezone"], - "properties" => { - "timezone" => { - "type" => "string", - "description" => "The timezone", - }, - }, - }, - }, - }, - }, - ], - }, - } - - expect(parsed_request).to eq(expected) - expect(response).to eq( - [ - DiscourseAi::Completions::ToolCall.new( - name: "time", - id: "e1bd7033-7244-4408-b088-1d33cbcf0b67", - parameters: { - "timezone" => "EST", - }, - ), - ], - ) - - # lets continue and ensure all messages are mapped correctly - prompt.push(type: :tool_call, name: "time", content: { timezone: "EST" }.to_json, id: "111") - prompt.push(type: :tool, name: "time", content: "1pm".to_json, id: "111") - - # lets just return the tool call again, this is about ensuring we encode the prompt right - stub_request(:post, stream_url) - .with do |inner_request| - request = inner_request - true - end - .to_return(status: 200, body: messages) - - response = [] - bedrock_mock.with_chunk_array_support do - proxy.generate(prompt, user: user, max_tokens: 200) { |partial| response << partial } - end - - expected = { - system: [{ text: "You are a helpful assistant." }], - messages: [ - { role: "user", content: [{ text: "what is the time in EST" }] }, - { - role: "assistant", - content: [{ toolUse: { toolUseId: "111", name: "time", input: nil } }], - }, - { - role: "user", - content: [{ toolResult: { toolUseId: "111", content: [{ json: "1pm" }] } }], - }, - ], - inferenceConfig: { - max_new_tokens: 200, - }, - toolConfig: { - tools: [ - { - toolSpec: { - name: "time", - description: "Will look up the current time", - inputSchema: { - json: { - type: "object", - properties: { - timezone: { - type: "string", - description: "The timezone", - }, - }, - required: ["timezone"], - }, - }, - }, - }, - ], - }, - } - - expect(JSON.parse(request.body, symbolize_names: true)).to eq(expected) - end -end diff --git a/spec/lib/completions/endpoints/ollama_spec.rb b/spec/lib/completions/endpoints/ollama_spec.rb deleted file mode 100644 index 3531a4a1..00000000 --- a/spec/lib/completions/endpoints/ollama_spec.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -require_relative "endpoint_compliance" - -class OllamaMock < EndpointMock - def response(content, tool_call: false) - message_content = - if tool_call - { content: "", tool_calls: [content] } - else - { content: content } - end - - { - created_at: "2024-09-25T06:47:21.283028Z", - model: "llama3.1", - message: { role: "assistant" }.merge(message_content), - done: true, - done_reason: "stop", - total_duration: 7_639_718_541, - load_duration: 299_886_663, - prompt_eval_count: 18, - prompt_eval_duration: 220_447_000, - eval_count: 18, - eval_duration: 220_447_000, - } - end - - def stub_response(prompt, response_text, tool_call: false) - WebMock - .stub_request(:post, "http://api.ollama.ai/api/chat") - .with(body: request_body(prompt, tool_call: tool_call)) - .to_return(status: 200, body: JSON.dump(response(response_text, tool_call: tool_call))) - end - - def stream_line(delta) - message_content = { content: delta } - - +{ - model: "llama3.1", - created_at: "2024-09-25T06:47:21.283028Z", - message: { role: "assistant" }.merge(message_content), - done: false, - }.to_json - end - - def stub_raw(chunks) - WebMock.stub_request(:post, "http://api.ollama.ai/api/chat").to_return( - status: 200, - body: chunks, - ) - end - - def stub_streamed_response(prompt, deltas) - chunks = deltas.each_with_index.map { |_, index| stream_line(deltas[index]) } - - chunks = - ( - chunks.join("\n\n") << { - model: "llama3.1", - created_at: "2024-09-25T06:47:21.283028Z", - message: { - role: "assistant", - content: "", - }, - done: true, - done_reason: "stop", - total_duration: 7_639_718_541, - load_duration: 299_886_663, - prompt_eval_count: 18, - prompt_eval_duration: 220_447_000, - eval_count: 18, - eval_duration: 220_447_000, - }.to_json - ).split("") - - WebMock - .stub_request(:post, "http://api.ollama.ai/api/chat") - .with(body: request_body(prompt)) - .to_return(status: 200, body: chunks) - - yield if block_given? - end - - def tool_response - { function: { name: "get_weather", arguments: { location: "Sydney", unit: "c" } } } - end - - def tool_payload - { - type: "function", - function: { - name: "get_weather", - description: "Get the weather in a city", - parameters: { - type: "object", - properties: { - location: { - type: "string", - description: "the city name", - }, - unit: { - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - }, - }, - required: %w[location unit], - }, - }, - } - end - - def request_body(prompt, tool_call: false) - model - .default_options - .merge(messages: prompt) - .tap do |b| - b[:stream] = false - b[:tools] = [tool_payload] if tool_call - end - .to_json - end -end - -RSpec.describe DiscourseAi::Completions::Endpoints::Ollama do - subject(:endpoint) { described_class.new(model) } - - fab!(:user) - fab!(:model) { Fabricate(:ollama_model) } - - let(:ollama_mock) { OllamaMock.new(endpoint) } - - let(:compliance) do - EndpointsCompliance.new(self, endpoint, DiscourseAi::Completions::Dialects::Ollama, user) - end - - before { enable_current_plugin } - - describe "#perform_completion!" do - context "when using regular mode" do - it "completes a trivial prompt and logs the response" do - compliance.regular_mode_simple_prompt(ollama_mock) - end - end - - context "with tools" do - it "returns a function invocation" do - compliance.regular_mode_tools(ollama_mock) - end - end - end - - describe "when using streaming mode" do - context "with simple prompts" do - it "completes a trivial prompt and logs the response" do - compliance.streaming_mode_simple_prompt(ollama_mock) - end - end - end -end diff --git a/spec/lib/completions/endpoints/open_ai_responses_api_spec.rb b/spec/lib/completions/endpoints/open_ai_responses_api_spec.rb deleted file mode 100644 index 41a10351..00000000 --- a/spec/lib/completions/endpoints/open_ai_responses_api_spec.rb +++ /dev/null @@ -1,242 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::Endpoints::OpenAi do - subject(:endpoint) { described_class.new(model) } - - fab!(:model) do - Fabricate( - :llm_model, - provider: "open_ai", - url: "https://api.openai.com/v1/responses", - provider_params: { - enable_responses_api: true, - }, - ) - end - - let(:prompt_with_tools) do - prompt = DiscourseAi::Completions::Prompt.new("echo: Hello") - prompt.tools = [ - DiscourseAi::Completions::ToolDefinition.new( - name: "echo", - description: "Used for testing of llms, will echo the param given to it", - parameters: [ - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.from_hash( - { name: "string", description: "string to echo", type: :string, required: true }, - ), - ], - ), - ] - prompt - end - - before { enable_current_plugin } - - it "can perform simple streaming completion" do - response_payload = <<~TEXT - event: response.created - data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_6848d84bee44819d98e5f4f5103562090333bc932679b022","object":"response","created_at":1749604427,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} - - event: response.in_progress - data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_6848d84bee44819d98e5f4f5103562090333bc932679b022","object":"response","created_at":1749604427,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} - - event: response.output_item.added - data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","type":"message","status":"in_progress","content":[],"role":"assistant"}} - - event: response.content_part.added - data: {"type":"response.content_part.added","sequence_number":3,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":""}} - - event: response.output_text.delta - data: {"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"delta":"Hello"} - - event: response.output_text.delta - data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"delta":" "} - - event: response.output_text.delta - data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"delta":"World"} - - event: response.output_text.done - data: {"type":"response.output_text.done","sequence_number":5,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"text":"Hello World"} - - event: response.content_part.done - data: {"type":"response.content_part.done","sequence_number":6,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":"Hello World"}} - - event: response.output_item.done - data: {"type":"response.output_item.done","sequence_number":7,"output_index":0,"item":{"id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Hello World"}],"role":"assistant"}} - - event: response.completed - data: {"type":"response.completed","sequence_number":8,"response":{"id":"resp_6848d84bee44819d98e5f4f5103562090333bc932679b022","object":"response","created_at":1749604427,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[{"id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Hello"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":35,"input_tokens_details":{"cached_tokens":5},"output_tokens":9,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":37},"user":null,"metadata":{}}} - TEXT - - partials = [] - - stub_request(:post, "https://api.openai.com/v1/responses").to_return( - status: 200, - body: response_payload, - ) - - model - .to_llm - .generate("Say: Hello World", user: Discourse.system_user) { |partial| partials << partial } - - expect(partials).to eq(["Hello", " ", "World"]) - - log = AiApiAuditLog.last - - # note: our report counts cache and request tokens separately see: DiscourseAi::Completions::Report - expect(log).to be_present - expect(log.request_tokens).to eq(30) - expect(log.response_tokens).to eq(9) - expect(log.cached_tokens).to eq(5) - end - - it "can properly stream tool calls" do - response_payload = <<~TEXT - event: response.created - data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_684910c81eec81a3a9222aa336d9fcf202d35c1819a50f63","object":"response","created_at":1749618888,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":{"type":"function","name":"echo"},"tools":[{"type":"function","description":"Used for testing of llms, will echo the param given to it","name":"echo","parameters":{"type":"object","properties":{"string":{"type":"string","description":"string to echo"}},"required":["string"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} - - event: response.in_progress - data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_684910c81eec81a3a9222aa336d9fcf202d35c1819a50f63","object":"response","created_at":1749618888,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":{"type":"function","name":"echo"},"tools":[{"type":"function","description":"Used for testing of llms, will echo the param given to it","name":"echo","parameters":{"type":"object","properties":{"string":{"type":"string","description":"string to echo"}},"required":["string"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} - - event: response.output_item.added - data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","type":"function_call","status":"in_progress","arguments":"","call_id":"call_TQyfNmFnKblzXl5rlcGeIsg5","name":"echo"}} - - event: response.function_call_arguments.delta - data: {"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"{\\""} - - event: response.function_call_arguments.delta - data: {"type":"response.function_call_arguments.delta","sequence_number":4,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"string"} - - event: response.function_call_arguments.delta - data: {"type":"response.function_call_arguments.delta","sequence_number":5,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"\\":\\""} - - event: response.function_call_arguments.delta - data: {"type":"response.function_call_arguments.delta","sequence_number":6,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"hello"} - - event: response.function_call_arguments.delta - data: {"type":"response.function_call_arguments.delta","sequence_number":7,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"\\"}"} - - event: response.function_call_arguments.done - data: {"type":"response.function_call_arguments.done","sequence_number":8,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"arguments":"{\\"string\\":\\"hello\\"}"} - - event: response.output_item.done - data: {"type":"response.output_item.done","sequence_number":9,"output_index":0,"item":{"id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","type":"function_call","status":"completed","arguments":"{\\"string\\":\\"hello\\"}","call_id":"call_TQyfNmFnKblzXl5rlcGeIsg5","name":"echo"}} - - event: response.completed - data: {"type":"response.completed","sequence_number":10,"response":{"id":"resp_684910c81eec81a3a9222aa336d9fcf202d35c1819a50f63","object":"response","created_at":1749618888,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[{"id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","type":"function_call","status":"completed","arguments":"{\\"string\\":\\"hello\\"}","call_id":"call_TQyfNmFnKblzXl5rlcGeIsg5","name":"echo"}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":{"type":"function","name":"echo"},"tools":[{"type":"function","description":"Used for testing of llms, will echo the param given to it","name":"echo","parameters":{"type":"object","properties":{"string":{"type":"string","description":"string to echo"}},"required":["string"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":71,"input_tokens_details":{"cached_tokens":0},"output_tokens":6,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":77},"user":null,"metadata":{}}} - - TEXT - - partials = [] - - stub_request(:post, "https://api.openai.com/v1/responses").to_return( - status: 200, - body: response_payload, - ) - - model - .to_llm - .generate( - prompt_with_tools, - user: Discourse.system_user, - partial_tool_calls: true, - ) { |partial| partials << partial.dup } - - # the partial tools are deduped - expect(partials.length).to eq(1) - - expect(partials.first).to be_a(DiscourseAi::Completions::ToolCall) - expect(partials.first.name).to eq("echo") - expect(partials.first.parameters).to eq({ string: "hello" }) - expect(partials.first.id).to eq("call_TQyfNmFnKblzXl5rlcGeIsg5") - end - - it "can handle non streaming tool calls" do - response_object = { - id: "resp_68491ed72974819f94652a73fb58109c08901d75ebf6c66e", - object: "response", - created_at: 1_749_622_487, - status: "completed", - background: false, - error: nil, - incomplete_details: nil, - instructions: nil, - max_output_tokens: nil, - model: "gpt-4.1-nano-2025-04-14", - output: [ - { - id: "fc_68491ed75e0c819f87462ff642c58d2e08901d75ebf6c66e", - type: "function_call", - status: "completed", - arguments: "{\"string\":\"sam\"}", - call_id: "call_UdxBpinIVc5nRZ0VnWJIgneA", - name: "echo", - }, - ], - parallel_tool_calls: true, - previous_response_id: nil, - reasoning: { - effort: nil, - summary: nil, - }, - service_tier: "default", - store: true, - temperature: 1.0, - text: { - format: { - type: "text", - }, - }, - tool_choice: { - type: "function", - name: "echo", - }, - tools: [ - { - type: "function", - description: "Used for testing of llms, will echo the param given to it", - name: "echo", - parameters: { - type: "object", - properties: { - string: { - type: "string", - description: "string to echo", - }, - }, - required: ["string"], - }, - strict: true, - }, - ], - top_p: 1.0, - truncation: "disabled", - usage: { - input_tokens: 73, - input_tokens_details: { - cached_tokens: 0, - }, - output_tokens: 6, - output_tokens_details: { - reasoning_tokens: 0, - }, - total_tokens: 79, - }, - user: nil, - metadata: { - }, - } - - stub_request(:post, "https://api.openai.com/v1/responses").to_return( - status: 200, - body: response_object.to_json, - ) - - result = model.to_llm.generate(prompt_with_tools, user: Discourse.system_user) - - expect(result).to be_a(DiscourseAi::Completions::ToolCall) - expect(result.name).to eq("echo") - expect(result.parameters).to eq({ string: "sam" }) - expect(result.id).to eq("call_UdxBpinIVc5nRZ0VnWJIgneA") - end -end diff --git a/spec/lib/completions/endpoints/open_ai_spec.rb b/spec/lib/completions/endpoints/open_ai_spec.rb deleted file mode 100644 index 403f281b..00000000 --- a/spec/lib/completions/endpoints/open_ai_spec.rb +++ /dev/null @@ -1,965 +0,0 @@ -# frozen_string_literal: true - -require_relative "endpoint_compliance" - -class OpenAiMock < EndpointMock - def response(content, tool_call: false) - message_content = - if tool_call - { tool_calls: [content] } - else - { content: content } - end - - { - id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S", - object: "chat.completion", - created: 1_678_464_820, - model: "gpt-3.5-turbo-0301", - usage: { - prompt_tokens: 8, - completion_tokens: 12, - total_tokens: 499, - }, - choices: [ - { message: { role: "assistant" }.merge(message_content), finish_reason: "stop", index: 0 }, - ], - } - end - - def stub_response(prompt, response_text, tool_call: false) - WebMock - .stub_request(:post, "https://api.openai.com/v1/chat/completions") - .with(body: request_body(prompt, tool_call: tool_call)) - .to_return(status: 200, body: JSON.dump(response(response_text, tool_call: tool_call))) - end - - def stream_line(delta, finish_reason: nil, tool_call: false) - message_content = - if tool_call - { tool_calls: [delta] } - else - { content: delta } - end - - +"data: " << { - id: "chatcmpl-#{SecureRandom.hex}", - object: "chat.completion.chunk", - created: 1_681_283_881, - model: "gpt-3.5-turbo-0301", - choices: [{ delta: message_content }], - finish_reason: finish_reason, - index: 0, - }.to_json - end - - def stub_raw(chunks, body_blk: nil) - stub = WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions") - stub.with(body: body_blk) if body_blk - stub.to_return(status: 200, body: chunks) - end - - def stub_streamed_response(prompt, deltas, tool_call: false, skip_body_check: false) - chunks = - deltas.each_with_index.map do |_, index| - if index == (deltas.length - 1) - stream_line(deltas[index], finish_reason: "stop_sequence", tool_call: tool_call) - else - stream_line(deltas[index], tool_call: tool_call) - end - end - - chunks = (chunks.join("\n\n") << "data: [DONE]").split("") - - mock = WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions") - - if !skip_body_check - mock = mock.with(body: request_body(prompt, stream: true, tool_call: tool_call)) - end - - mock.to_return(status: 200, body: chunks) - - yield if block_given? - end - - def tool_deltas - [ - { id: tool_id, function: {} }, - { id: tool_id, function: { name: "get_weather", arguments: "" } }, - { id: tool_id, function: { arguments: "" } }, - { id: tool_id, function: { arguments: "{" } }, - { id: tool_id, function: { arguments: " \"location\": \"Sydney\"" } }, - { id: tool_id, function: { arguments: " ,\"unit\": \"c\" }" } }, - ] - end - - def tool_response - { - id: tool_id, - function: { - name: "get_weather", - arguments: { location: "Sydney", unit: "c" }.to_json, - }, - } - end - - def tool_id - "tool_0" - end - - def tool_payload - { - type: "function", - function: { - name: "get_weather", - description: "Get the weather in a city", - parameters: { - type: "object", - properties: { - location: { - type: "string", - description: "the city name", - }, - unit: { - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - }, - }, - required: %w[location unit], - }, - }, - } - end - - def request_body(prompt, stream: false, tool_call: false) - model - .default_options - .merge(messages: prompt) - .tap do |b| - if stream - b[:stream] = true - b[:stream_options] = { include_usage: true } - end - b[:tools] = [tool_payload] if tool_call - end - .to_json - end -end - -RSpec.describe DiscourseAi::Completions::Endpoints::OpenAi do - subject(:endpoint) { described_class.new(model) } - - fab!(:user) - fab!(:model) { Fabricate(:llm_model) } - - let(:echo_tool) do - { - name: "echo", - description: "echo something", - parameters: [{ name: "text", type: "string", description: "text to echo", required: true }], - } - end - - let(:tools) { [echo_tool] } - - let(:open_ai_mock) { OpenAiMock.new(endpoint) } - - let(:compliance) do - EndpointsCompliance.new(self, endpoint, DiscourseAi::Completions::Dialects::ChatGpt, user) - end - - let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } - let(:upload100x100) do - UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) - end - - before { enable_current_plugin } - - describe "max tokens for reasoning models" do - it "uses max_completion_tokens for reasoning models" do - model.update!(name: "o3-mini", max_output_tokens: 999) - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot", - messages: [type: :user, content: "hello"], - ) - - response_text = <<~RESPONSE - data: {"id":"chatcmpl-B2VwlY6KzSDtHvg8pN1VAfRhhLFgn","object":"chat.completion.chunk","created":1739939159,"model":"o3-mini-2025-01-31","service_tier":"default","system_fingerprint":"fp_ef58bd3122","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"finish_reason":null}],"usage":null} - - data: {"id":"chatcmpl-B2VwlY6KzSDtHvg8pN1VAfRhhLFgn","object":"chat.completion.chunk","created":1739939159,"model":"o3-mini-2025-01-31","service_tier":"default","system_fingerprint":"fp_ef58bd3122","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}],"usage":null} - - data: {"id":"chatcmpl-B2VwlY6KzSDtHvg8pN1VAfRhhLFgn","object":"chat.completion.chunk","created":1739939159,"model":"o3-mini-2025-01-31","service_tier":"default","system_fingerprint":"fp_ef58bd3122","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":null} - - data: {"id":"chatcmpl-B2VwlY6KzSDtHvg8pN1VAfRhhLFgn","object":"chat.completion.chunk","created":1739939159,"model":"o3-mini-2025-01-31","service_tier":"default","system_fingerprint":"fp_ef58bd3122","choices":[],"usage":{"prompt_tokens":22,"completion_tokens":203,"total_tokens":225,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":192,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} - - data: [DONE] - RESPONSE - - body_parsed = nil - stub_request(:post, "https://api.openai.com/v1/chat/completions").with( - body: ->(body) { body_parsed = JSON.parse(body) }, - ).to_return(body: response_text) - result = +"" - llm.generate(prompt, user: user, max_tokens: 1000) { |chunk| result << chunk } - - expect(result).to eq("hello") - expect(body_parsed["max_completion_tokens"]).to eq(999) - - llm.generate(prompt, user: user, max_tokens: 100) { |chunk| result << chunk } - expect(body_parsed["max_completion_tokens"]).to eq(100) - - llm.generate(prompt, user: user) { |chunk| result << chunk } - expect(body_parsed["max_completion_tokens"]).to eq(999) - end - end - - describe "repeat calls" do - it "can properly reset context" do - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - tools = [ - { - name: "echo", - description: "echo something", - parameters: [ - { name: "text", type: "string", description: "text to echo", required: true }, - ], - }, - ] - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot", - messages: [type: :user, id: "user1", content: "echo hello"], - tools: tools, - ) - - response = { - id: "chatcmpl-9JxkAzzaeO4DSV3omWvok9TKhCjBH", - object: "chat.completion", - created: 1_714_544_914, - model: "gpt-4-turbo-2024-04-09", - choices: [ - { - index: 0, - message: { - role: "assistant", - content: nil, - tool_calls: [ - { - id: "call_I8LKnoijVuhKOM85nnEQgWwd", - type: "function", - function: { - name: "echo", - arguments: "{\"text\":\"hello\"}", - }, - }, - ], - }, - logprobs: nil, - finish_reason: "tool_calls", - }, - ], - usage: { - prompt_tokens: 55, - completion_tokens: 13, - total_tokens: 68, - }, - system_fingerprint: "fp_ea6eb70039", - }.to_json - - stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return(body: response) - - result = llm.generate(prompt, user: user) - - tool_call = - DiscourseAi::Completions::ToolCall.new( - id: "call_I8LKnoijVuhKOM85nnEQgWwd", - name: "echo", - parameters: { - text: "hello", - }, - ) - - expect(result).to eq(tool_call) - - stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - body: { choices: [message: { content: "OK" }] }.to_json, - ) - - result = llm.generate(prompt, user: user) - - expect(result).to eq("OK") - end - end - - describe "max tokens remapping" do - it "remaps max_tokens to max_completion_tokens for reasoning models" do - model.update!(name: "o3-mini") - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - body_parsed = nil - stub_request(:post, "https://api.openai.com/v1/chat/completions").with( - body: ->(body) { body_parsed = JSON.parse(body) }, - ).to_return(status: 200, body: { choices: [{ message: { content: "hello" } }] }.to_json) - - llm.generate("test", user: user, max_tokens: 1000) - - expect(body_parsed["max_completion_tokens"]).to eq(1000) - expect(body_parsed["max_tokens"]).to be_nil - end - end - - describe "forced tool use" do - it "can properly force tool use" do - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - tools = [ - { - name: "echo", - description: "echo something", - parameters: [ - { name: "text", type: "string", description: "text to echo", required: true }, - ], - }, - ] - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot", - messages: [type: :user, id: "user1", content: "echo hello"], - tools: tools, - tool_choice: "echo", - ) - - response = { - id: "chatcmpl-9JxkAzzaeO4DSV3omWvok9TKhCjBH", - object: "chat.completion", - created: 1_714_544_914, - model: "gpt-4-turbo-2024-04-09", - choices: [ - { - index: 0, - message: { - role: "assistant", - content: nil, - tool_calls: [ - { - id: "call_I8LKnoijVuhKOM85nnEQgWwd", - type: "function", - function: { - name: "echo", - arguments: "{\"text\":\"h<e>llo\"}", - }, - }, - ], - }, - logprobs: nil, - finish_reason: "tool_calls", - }, - ], - usage: { - prompt_tokens: 55, - completion_tokens: 13, - total_tokens: 68, - }, - system_fingerprint: "fp_ea6eb70039", - }.to_json - - body_json = nil - stub_request(:post, "https://api.openai.com/v1/chat/completions").with( - body: proc { |body| body_json = JSON.parse(body, symbolize_names: true) }, - ).to_return(body: response) - - result = llm.generate(prompt, user: user, max_tokens: 1000) - - expect(body_json[:tool_choice]).to eq({ type: "function", function: { name: "echo" } }) - # we expect this not to be remapped on older non reasoning models - expect(body_json[:max_tokens]).to eq(1000) - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(55) - expect(log.response_tokens).to eq(13) - expect(log.duration_msecs).not_to be_nil - - expected = - DiscourseAi::Completions::ToolCall.new( - id: "call_I8LKnoijVuhKOM85nnEQgWwd", - name: "echo", - parameters: { - text: "h<e>llo", - }, - ) - - expect(result).to eq(expected) - - stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - body: { choices: [message: { content: "OK" }] }.to_json, - ) - - result = llm.generate(prompt, user: user) - - expect(result).to eq("OK") - end - end - - describe "structured outputs" do - it "falls back to best-effort parsing on broken JSON responses" do - prompt = compliance.generic_prompt - deltas = ["```json\n{ message: 'hel", "lo' }"] - - model_params = { - response_format: { - json_schema: { - schema: { - properties: { - message: { - type: "string", - }, - }, - }, - }, - }, - } - - read_properties = [] - open_ai_mock.with_chunk_array_support do - # skip body check cause of response format - open_ai_mock.stub_streamed_response(prompt, deltas, skip_body_check: true) - - dialect = compliance.dialect(prompt: prompt) - - endpoint.perform_completion!(dialect, user, model_params) do |partial| - read_properties << partial.read_buffered_property(:message) - end - end - - expect(read_properties.join).to eq("hello") - end - end - - describe "disabled tool use" do - it "can properly disable tool use with :none" do - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - - tools = [ - { - name: "echo", - description: "echo something", - parameters: [ - { name: "text", type: "string", description: "text to echo", required: true }, - ], - }, - ] - - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot", - messages: [type: :user, id: "user1", content: "don't use any tools please"], - tools: tools, - tool_choice: :none, - ) - - response = { - id: "chatcmpl-9JxkAzzaeO4DSV3omWvok9TKhCjBH", - object: "chat.completion", - created: 1_714_544_914, - model: "gpt-4-turbo-2024-04-09", - choices: [ - { - index: 0, - message: { - role: "assistant", - content: "I won't use any tools. Here's a direct response instead.", - }, - logprobs: nil, - finish_reason: "stop", - }, - ], - usage: { - prompt_tokens: 55, - completion_tokens: 13, - total_tokens: 68, - }, - system_fingerprint: "fp_ea6eb70039", - }.to_json - - body_json = nil - stub_request(:post, "https://api.openai.com/v1/chat/completions").with( - body: proc { |body| body_json = JSON.parse(body, symbolize_names: true) }, - ).to_return(body: response) - - result = llm.generate(prompt, user: user) - - # Verify that tool_choice is set to "none" in the request - expect(body_json[:tool_choice]).to eq("none") - expect(result).to eq("I won't use any tools. Here's a direct response instead.") - end - end - - describe "parameter disabling" do - it "excludes disabled parameters from the request" do - model.update!(provider_params: { disable_top_p: true, disable_temperature: true }) - - parsed_body = nil - stub_request(:post, "https://api.openai.com/v1/chat/completions").with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - ).to_return( - status: 200, - body: { choices: [{ message: { content: "test response" } }] }.to_json, - ) - - dialect = compliance.dialect(prompt: compliance.generic_prompt) - - # Request with parameters that should be ignored - endpoint.perform_completion!(dialect, user, { top_p: 0.9, temperature: 0.8, max_tokens: 100 }) - - # Verify disabled parameters aren't included - expect(parsed_body).not_to have_key(:top_p) - expect(parsed_body).not_to have_key(:temperature) - - # Verify other parameters still work - expect(parsed_body).to have_key(:max_tokens) - expect(parsed_body[:max_tokens]).to eq(100) - end - end - - describe "image support" do - it "can handle images" do - model = Fabricate(:llm_model, vision_enabled: true) - llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}") - prompt = - DiscourseAi::Completions::Prompt.new( - "You are image bot", - messages: [type: :user, id: "user1", content: ["hello", { upload_id: upload100x100.id }]], - ) - - encoded = prompt.encoded_uploads(prompt.messages.last) - - parsed_body = nil - - stub_request(:post, "https://api.openai.com/v1/chat/completions").with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - ).to_return(status: 200, body: { choices: [message: { content: "nice pic" }] }.to_json) - - completion = llm.generate(prompt, user: user) - - expect(completion).to eq("nice pic") - expected_body = { - model: "gpt-4-turbo", - messages: [ - { role: "system", content: "You are image bot" }, - { - role: "user", - content: [ - { type: "text", text: "hello" }, - { - type: "image_url", - image_url: { - url: "data:#{encoded[0][:mime_type]};base64,#{encoded[0][:base64]}", - }, - }, - ], - name: "user1", - }, - ], - } - expect(parsed_body).to eq(expected_body) - end - end - - describe "#perform_completion!" do - context "when using XML tool calls format" do - let(:xml_tool_call_response) { <<~XML } - <function_calls> - <invoke> - <tool_name>get_weather</tool_name> - <parameters> - <location>Sydney</location> - <unit>c</unit> - <is_it_hot>true</is_it_hot> - </parameters> - </invoke> - </function_calls> - XML - - let(:weather_tool) do - { - name: "get_weather", - description: "get weather", - parameters: [ - { name: "location", type: "string", description: "location", required: true }, - { name: "unit", type: "string", description: "unit", required: true, enum: %w[c f] }, - { name: "is_it_hot", type: "boolean", description: "is it hot" }, - ], - } - end - - it "parses XML tool calls" do - response = { - id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S", - object: "chat.completion", - created: 1_678_464_820, - model: "gpt-3.5-turbo-0301", - usage: { - prompt_tokens: 8, - completion_tokens: 13, - total_tokens: 499, - }, - choices: [ - { - message: { - role: "assistant", - content: xml_tool_call_response, - }, - finish_reason: "stop", - index: 0, - }, - ], - }.to_json - - endpoint.llm_model.update!(provider_params: { disable_native_tools: true }) - body = nil - open_ai_mock.stub_raw(response, body_blk: proc { |inner_body| body = inner_body }) - - dialect = compliance.dialect(prompt: compliance.generic_prompt(tools: [weather_tool])) - tool_call = endpoint.perform_completion!(dialect, user) - - body_parsed = JSON.parse(body, symbolize_names: true) - expect(body_parsed[:tools]).to eq(nil) - - expect(body_parsed[:messages][0][:content]).to include("<function_calls>") - - expect(tool_call.name).to eq("get_weather") - expect(tool_call.parameters).to eq({ location: "Sydney", unit: "c", is_it_hot: true }) - end - end - - context "when using regular mode" do - context "with simple prompts" do - it "completes a trivial prompt and logs the response" do - compliance.regular_mode_simple_prompt(open_ai_mock) - end - end - - context "with tools" do - it "returns a function invocation" do - compliance.regular_mode_tools(open_ai_mock) - end - end - end - - it "falls back to non-streaming mode when streaming is disabled" do - model.update!(provider_params: { disable_streaming: true }) - - response = { - id: "chatcmpl-123", - object: "chat.completion", - created: 1_677_652_288, - choices: [ - { - message: { - role: "assistant", - content: "Hello there", - }, - index: 0, - finish_reason: "stop", - }, - ], - } - - parsed_body = nil - stub_request(:post, "https://api.openai.com/v1/chat/completions").with( - body: - proc do |req_body| - parsed_body = JSON.parse(req_body, symbolize_names: true) - true - end, - ).to_return(status: 200, body: response.to_json) - - chunks = [] - dialect = compliance.dialect(prompt: compliance.generic_prompt) - endpoint.perform_completion!(dialect, user) { |chunk| chunks << chunk } - - expect(parsed_body).not_to have_key(:stream) - - expect(chunks).to eq(["Hello there"]) - end - - describe "when using streaming mode" do - context "with simple prompts" do - it "completes a trivial prompt and logs the response" do - compliance.streaming_mode_simple_prompt(open_ai_mock) - end - - it "will automatically recover from a bad payload" do - called = false - - # this should not happen, but lets ensure nothing bad happens - # the row with test1 is invalid json - raw_data = <<~TEXT.strip - d|a|t|a|:| |{|"choices":[{"delta":{"content":"test,"}}]} - - data: {"choices":[{"delta":{"content":"test|1| |,"}}] - - data: {"choices":[{"delta":|{"content":"test2 ,"}}]} - - data: {"choices":[{"delta":{"content":"test3,"}}]|} - - data: {"choices":[{|"|d|elta":{"content":"test4"}}]|} - - data: [D|ONE] - TEXT - - chunks = raw_data.split("|") - - open_ai_mock.with_chunk_array_support do - open_ai_mock.stub_raw(chunks) - - partials = [] - - endpoint.perform_completion!(compliance.dialect, user) { |partial| partials << partial } - - called = true - expect(partials.join).to eq("test,test2 ,test3,test4") - end - expect(called).to be(true) - end - end - - context "with tools" do - it "returns a function invocation" do - compliance.streaming_mode_tools(open_ai_mock) - end - - it "properly handles multiple tool calls" do - raw_data = <<~TEXT.strip - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"role":"assistant","content":null},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_3Gyr3HylFJwfrtKrL6NaIit1","type":"function","function":{"name":"search","arguments":""}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"se"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"arch_"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"query\\""}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":": \\"D"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"iscou"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"rse AI"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" bot"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"}"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_H7YkbgYurHpyJqzwUN4bghwN","type":"function","function":{"name":"search","arguments":""}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"qu"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"ery\\":"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":" \\"Disc"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"ours"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"e AI "}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"bot2\\"}"}}]},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xjcr5ZOGZ9v8BDYCx0iwe57lJAGk","object":"chat.completion.chunk","created":1709247429,"model":"gpt-4-0125-preview","system_fingerprint":"fp_91aa3742b1","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]} - - data: [DONE] -TEXT - - open_ai_mock.stub_raw(raw_data) - response = [] - - dialect = compliance.dialect(prompt: compliance.generic_prompt(tools: tools)) - - endpoint.perform_completion!(dialect, user) { |partial| response << partial } - - tool_calls = [ - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "call_3Gyr3HylFJwfrtKrL6NaIit1", - parameters: { - search_query: "Discourse AI bot", - }, - ), - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "call_H7YkbgYurHpyJqzwUN4bghwN", - parameters: { - query: "Discourse AI bot2", - }, - ), - ] - - expect(response).to eq(tool_calls) - end - - it "properly handles newlines" do - response = <<~TEXT.strip - data: {"id":"chatcmpl-ASngi346UA9k006bF6GBRV66tEJfQ","object":"chat.completion.chunk","created":1731427548,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_159d8341cc","choices":[{"index":0,"delta":{"content":":\\n\\n"},"logprobs":null,"finish_reason":null}],"usage":null} - - data: {"id":"chatcmpl-ASngi346UA9k006bF6GBRV66tEJfQ","object":"chat.completion.chunk","created":1731427548,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_159d8341cc","choices":[{"index":0,"delta":{"content":"```"},"logprobs":null,"finish_reason":null}],"usage":null} - - data: {"id":"chatcmpl-ASngi346UA9k006bF6GBRV66tEJfQ","object":"chat.completion.chunk","created":1731427548,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_159d8341cc","choices":[{"index":0,"delta":{"content":"ruby"},"logprobs":null,"finish_reason":null}],"usage":null} - - data: {"id":"chatcmpl-ASngi346UA9k006bF6GBRV66tEJfQ","object":"chat.completion.chunk","created":1731427548,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_159d8341cc","choices":[{"index":0,"delta":{"content":"\\n"},"logprobs":null,"finish_reason":null}],"usage":null} - - data: {"id":"chatcmpl-ASngi346UA9k006bF6GBRV66tEJfQ","object":"chat.completion.chunk","created":1731427548,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_159d8341cc","choices":[{"index":0,"delta":{"content":"def"},"logprobs":null,"finish_reason":null}],"usage":null} - TEXT - - open_ai_mock.stub_raw(response) - partials = [] - - dialect = compliance.dialect(prompt: compliance.generic_prompt) - endpoint.perform_completion!(dialect, user) { |partial| partials << partial } - - expect(partials).to eq([":\n\n", "```", "ruby", "\n", "def"]) - end - - it "uses proper token accounting" do - response = <<~TEXT.strip - data: {"id":"chatcmpl-9OZidiHncpBhhNMcqCus9XiJ3TkqR","object":"chat.completion.chunk","created":1715644203,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_729ea513f7","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"usage":null}| - - data: {"id":"chatcmpl-9OZidiHncpBhhNMcqCus9XiJ3TkqR","object":"chat.completion.chunk","created":1715644203,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_729ea513f7","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null}| - - data: {"id":"chatcmpl-9OZidiHncpBhhNMcqCus9XiJ3TkqR","object":"chat.completion.chunk","created":1715644203,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_729ea513f7","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null}| - - data: {"id":"chatcmpl-9OZidiHncpBhhNMcqCus9XiJ3TkqR","object":"chat.completion.chunk","created":1715644203,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_729ea513f7","choices":[],"usage":{"prompt_tokens":20,"completion_tokens":9,"total_tokens":29}}| - - data: [DONE] - TEXT - - chunks = response.split("|") - open_ai_mock.with_chunk_array_support do - open_ai_mock.stub_raw(chunks) - partials = [] - - dialect = compliance.dialect(prompt: compliance.generic_prompt) - endpoint.perform_completion!(dialect, user) { |partial| partials << partial } - - expect(partials).to eq(["Hello"]) - - log = AiApiAuditLog.order("id desc").first - - expect(log.request_tokens).to eq(20) - expect(log.response_tokens).to eq(9) - end - end - - it "properly handles multiple params in partial tool calls" do - # this is not working and it is driving me nuts so I will use a sledghammer - # text = plugin_file_from_fixtures("openai_artifact_call.txt", "bot") - - path = File.join(__dir__, "../../../fixtures/bot", "openai_artifact_call.txt") - text = File.read(path) - - partials = [] - open_ai_mock.with_chunk_array_support do - open_ai_mock.stub_raw(text.scan(/.*\n/)) - - dialect = compliance.dialect(prompt: compliance.generic_prompt(tools: tools)) - endpoint.perform_completion!(dialect, user, partial_tool_calls: true) do |partial| - partials << partial.dup - end - end - - expect(partials.compact.length).to eq(128) - - params = - partials - .map { |p| p.parameters if p.is_a?(DiscourseAi::Completions::ToolCall) && p.partial? } - .compact - - lengths = {} - params.each do |p| - p.each do |k, v| - if lengths[k] && lengths[k] > v.length - expect(lengths[k]).to be > v.length - else - lengths[k] = v.length - end - end - end - - audit_log = AiApiAuditLog.order("id desc").first - expect(audit_log.cached_tokens).to eq(33) - end - - it "properly handles spaces in tools payload and partial tool calls" do - raw_data = <<~TEXT.strip - data: {"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"func_id","type":"function","function":{"name":"go|ogle","arg|uments":""}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\\""}}]}}]} - - data: {"ch|oices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "query"}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "\\":\\""}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "Ad"}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "a|b"}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "as"}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": |"| "}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "9"}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "."}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"argume|nts": "1"}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "\\"}"}}]}}]} - - data: {"choices": [{"index": 0, "delta": {"tool_calls": []}}]} - - data: [D|ONE] - TEXT - - chunks = raw_data.split("|") - - open_ai_mock.with_chunk_array_support do - open_ai_mock.stub_raw(chunks) - partials = [] - - dialect = compliance.dialect(prompt: compliance.generic_prompt(tools: tools)) - endpoint.perform_completion!(dialect, user, partial_tool_calls: true) do |partial| - partials << partial.dup - end - - tool_call = - DiscourseAi::Completions::ToolCall.new( - id: "func_id", - name: "google", - parameters: { - query: "Adabas 9.1", - }, - ) - - expect(partials.last).to eq(tool_call) - - progress = partials.map { |p| p.parameters[:query] } - expect(progress).to eq(["Ad", "Adabas", "Adabas 9.", "Adabas 9.1"]) - end - end - end - end - end -end diff --git a/spec/lib/completions/endpoints/open_router_spec.rb b/spec/lib/completions/endpoints/open_router_spec.rb deleted file mode 100644 index 628c6f27..00000000 --- a/spec/lib/completions/endpoints/open_router_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::Endpoints::OpenRouter do - fab!(:user) - fab!(:open_router_model) - - subject(:endpoint) { described_class.new(open_router_model) } - - before { enable_current_plugin } - - it "supports provider quantization and order selection" do - open_router_model.provider_params["provider_quantizations"] = "int8,int16" - open_router_model.provider_params["provider_order"] = "Google, Amazon Bedrock" - open_router_model.save! - - parsed_body = nil - stub_request(:post, open_router_model.url).with( - body: proc { |body| parsed_body = JSON.parse(body, symbolize_names: true) }, - headers: { - "Content-Type" => "application/json", - "X-Title" => "Discourse AI", - "HTTP-Referer" => "https://www.discourse.org/ai", - "Authorization" => "Bearer 123", - }, - ).to_return( - status: 200, - body: { "choices" => [message: { role: "assistant", content: "world" }] }.to_json, - ) - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{open_router_model.id}") - result = proxy.generate("hello", user: user) - - expect(result).to eq("world") - - expected = { - model: "openrouter-1.0", - messages: [ - { role: "system", content: "You are a helpful bot" }, - { role: "user", content: "hello" }, - ], - provider: { - quantizations: %w[int8 int16], - order: ["Google", "Amazon Bedrock"], - }, - } - - expect(parsed_body).to eq(expected) - end - - it "excludes disabled parameters from the request" do - open_router_model.update!(provider_params: { disable_top_p: true, disable_temperature: true }) - - parsed_body = nil - stub_request(:post, open_router_model.url).with( - body: proc { |body| parsed_body = JSON.parse(body, symbolize_names: true) }, - headers: { - "Content-Type" => "application/json", - "X-Title" => "Discourse AI", - "HTTP-Referer" => "https://www.discourse.org/ai", - "Authorization" => "Bearer 123", - }, - ).to_return( - status: 200, - body: { "choices" => [message: { role: "assistant", content: "test response" }] }.to_json, - ) - - proxy = DiscourseAi::Completions::Llm.proxy("custom:#{open_router_model.id}") - - # Request with parameters that should be ignored - proxy.generate("test", user: user, top_p: 0.9, temperature: 0.8, max_tokens: 500) - - # Verify disabled parameters aren't included - expect(parsed_body).not_to have_key(:top_p) - expect(parsed_body).not_to have_key(:temperature) - - # Verify other parameters still work - expect(parsed_body).to have_key(:max_tokens) - expect(parsed_body[:max_tokens]).to eq(500) - end -end diff --git a/spec/lib/completions/endpoints/samba_nova_spec.rb b/spec/lib/completions/endpoints/samba_nova_spec.rb deleted file mode 100644 index bacff00d..00000000 --- a/spec/lib/completions/endpoints/samba_nova_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::Endpoints::SambaNova do - fab!(:llm_model) { Fabricate(:samba_nova_model) } - let(:llm) { llm_model.to_llm } - - before { enable_current_plugin } - - it "can stream completions" do - body = <<~PARTS - data: {"id": "4c5e4a44-e847-467d-b9cd-d2f6530678cd", "object": "chat.completion.chunk", "created": 1721336361, "model": "llama3-8b", "system_fingerprint": "fastcoe", "choices": [{"index": 0, "delta": {"content": "I am a bot"}, "logprobs": null, "finish_reason": null}]} - -data: {"id": "4c5e4a44-e847-467d-b9cd-d2f6530678cd", "object": "chat.completion.chunk", "created": 1721336361, "model": "llama3-8b", "system_fingerprint": "fastcoe", "choices": [], "usage": {"is_last_response": true, "total_tokens": 62, "prompt_tokens": 21, "completion_tokens": 41, "time_to_first_token": 0.09152531623840332, "end_time": 1721336361.582011, "start_time": 1721336361.413994, "total_latency": 0.16801691055297852, "total_tokens_per_sec": 369.010475171488, "completion_tokens_per_sec": 244.02305616179046, "completion_tokens_after_first_per_sec": 522.9332759819093, "completion_tokens_after_first_per_sec_first_ten": 1016.0004844667837}} - -data: [DONE] - PARTS - - stub_request(:post, "https://api.sambanova.ai/v1/chat/completions").with( - body: - "{\"model\":\"samba-nova\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a helpful bot\"},{\"role\":\"user\",\"content\":\"who are you?\"}],\"stream\":true}", - headers: { - "Authorization" => "Bearer ABC", - "Content-Type" => "application/json", - }, - ).to_return(status: 200, body: body, headers: {}) - - response = [] - llm.generate("who are you?", user: Discourse.system_user) { |partial| response << partial } - - expect(response).to eq(["I am a bot"]) - - log = AiApiAuditLog.order(:id).last - - expect(log.request_tokens).to eq(21) - expect(log.response_tokens).to eq(41) - end - - it "can perform regular completions" do - body = { choices: [message: { content: "I am a bot" }] }.to_json - - stub_request(:post, "https://api.sambanova.ai/v1/chat/completions").with( - body: - "{\"model\":\"samba-nova\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a helpful bot\"},{\"role\":\"user\",\"content\":\"who are you?\"}]}", - headers: { - "Authorization" => "Bearer ABC", - "Content-Type" => "application/json", - }, - ).to_return(status: 200, body: body, headers: {}) - - response = llm.generate("who are you?", user: Discourse.system_user) - - expect(response).to eq("I am a bot") - end -end diff --git a/spec/lib/completions/endpoints/vllm_spec.rb b/spec/lib/completions/endpoints/vllm_spec.rb deleted file mode 100644 index c91ce067..00000000 --- a/spec/lib/completions/endpoints/vllm_spec.rb +++ /dev/null @@ -1,277 +0,0 @@ -# frozen_string_literal: true - -require_relative "endpoint_compliance" - -class VllmMock < EndpointMock - def response(content) - { - id: "cmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S", - object: "chat.completion", - created: 1_678_464_820, - model: "mistralai/Mixtral-8x7B-Instruct-v0.1", - usage: { - prompt_tokens: 337, - completion_tokens: 162, - total_tokens: 499, - }, - choices: [ - { message: { role: "assistant", content: content }, finish_reason: "stop", index: 0 }, - ], - } - end - - def stub_response(prompt, response_text, tool_call: false) - WebMock - .stub_request(:post, "https://test.dev/v1/chat/completions") - .with(body: model.default_options.merge(messages: prompt).to_json) - .to_return(status: 200, body: JSON.dump(response(response_text))) - end - - def stream_line(delta, finish_reason: nil) - +"data: " << { - id: "cmpl-#{SecureRandom.hex}", - created: 1_681_283_881, - model: "mistralai/Mixtral-8x7B-Instruct-v0.1", - choices: [{ delta: { content: delta } }], - index: 0, - }.to_json - end - - def stub_streamed_response(prompt, deltas, tool_call: false) - chunks = - deltas.each_with_index.map do |_, index| - if index == (deltas.length - 1) - stream_line(deltas[index], finish_reason: "stop_sequence") - else - stream_line(deltas[index]) - end - end - - chunks = (chunks.join("\n\n") << "data: [DONE]").split("") - - WebMock - .stub_request(:post, "https://test.dev/v1/chat/completions") - .with( - body: - model - .default_options - .merge(messages: prompt, stream: true, stream_options: { include_usage: true }) - .to_json, - ) - .to_return(status: 200, body: chunks) - end -end - -RSpec.describe DiscourseAi::Completions::Endpoints::Vllm do - subject(:endpoint) { described_class.new(llm_model) } - - fab!(:llm_model) { Fabricate(:vllm_model) } - fab!(:user) - - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:vllm_mock) { VllmMock.new(endpoint) } - - let(:compliance) do - EndpointsCompliance.new( - self, - endpoint, - DiscourseAi::Completions::Dialects::OpenAiCompatible, - user, - ) - end - - let(:dialect) do - DiscourseAi::Completions::Dialects::OpenAiCompatible.new(generic_prompt, llm_model) - end - let(:prompt) { dialect.translate } - - let(:request_body) { model.default_options.merge(messages: prompt).to_json } - let(:stream_request_body) { model.default_options.merge(messages: prompt, stream: true).to_json } - - before { enable_current_plugin } - - describe "tool support" do - it "is able to invoke XML tools correctly" do - xml = <<~XML - <function_calls> - <invoke> - <tool_name>calculate</tool_name> - <parameters> - <expression>1+1</expression></parameters> - </invoke> - </function_calls> - should be ignored - XML - - body = { - id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S", - object: "chat.completion", - created: 1_678_464_820, - model: "gpt-3.5-turbo-0301", - usage: { - prompt_tokens: 337, - completion_tokens: 162, - total_tokens: 499, - }, - choices: [ - { message: { role: "assistant", content: xml }, finish_reason: "stop", index: 0 }, - ], - } - tool = { - name: "calculate", - description: "calculate something", - parameters: [ - { - name: "expression", - type: "string", - description: "expression to calculate", - required: true, - }, - ], - } - - stub_request(:post, "https://test.dev/v1/chat/completions").to_return( - status: 200, - body: body.to_json, - ) - - prompt = - DiscourseAi::Completions::Prompt.new( - "You a calculator", - messages: [{ type: :user, id: "user1", content: "calculate 2758975 + 21.11" }], - tools: [tool], - ) - - result = llm.generate(prompt, user: Discourse.system_user) - - expected = - DiscourseAi::Completions::ToolCall.new( - name: "calculate", - id: "tool_0", - parameters: { - expression: "1+1", - }, - ) - - expect(result).to eq(expected) - end - end - - it "correctly accounts for tokens in non streaming mode" do - body = (<<~TEXT).strip - {"id":"chat-c580e4a9ebaa44a0becc802ed5dc213a","object":"chat.completion","created":1731294404,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"message":{"role":"assistant","content":"Random Number Generator Produces Smallest Possible Result","tool_calls":[]},"logprobs":null,"finish_reason":"stop","stop_reason":null}],"usage":{"prompt_tokens":146,"total_tokens":156,"completion_tokens":10},"prompt_logprobs":null} - TEXT - - stub_request(:post, "https://test.dev/v1/chat/completions").to_return(status: 200, body: body) - - result = llm.generate("generate a title", user: Discourse.system_user) - - expect(result).to eq("Random Number Generator Produces Smallest Possible Result") - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(146) - expect(log.response_tokens).to eq(10) - end - - it "can properly include usage in streaming mode" do - payload = <<~TEXT.strip - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":46,"completion_tokens":0}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":47,"completion_tokens":1}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" Sam"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":48,"completion_tokens":2}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":49,"completion_tokens":3}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" It"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":50,"completion_tokens":4}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":"'s"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":51,"completion_tokens":5}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" nice"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":52,"completion_tokens":6}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":53,"completion_tokens":7}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" meet"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":54,"completion_tokens":8}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":55,"completion_tokens":9}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":56,"completion_tokens":10}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" Is"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":57,"completion_tokens":11}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" there"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":58,"completion_tokens":12}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" something"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":59,"completion_tokens":13}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":60,"completion_tokens":14}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":61,"completion_tokens":15}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" help"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":62,"completion_tokens":16}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":63,"completion_tokens":17}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" with"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":64,"completion_tokens":18}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" or"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":65,"completion_tokens":19}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" would"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":66,"completion_tokens":20}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":67,"completion_tokens":21}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" like"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":68,"completion_tokens":22}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":69,"completion_tokens":23}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":" chat"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":70,"completion_tokens":24}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":71,"completion_tokens":25}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[{"index":0,"delta":{"content":""},"logprobs":null,"finish_reason":"stop","stop_reason":null}],"usage":{"prompt_tokens":46,"total_tokens":72,"completion_tokens":26}} - - data: {"id":"chat-b183bb5829194e8891cacceabfdb5274","object":"chat.completion.chunk","created":1731295402,"model":"meta-llama/Meta-Llama-3.1-70B-Instruct","choices":[],"usage":{"prompt_tokens":46,"total_tokens":72,"completion_tokens":26}} - - data: [DONE] - TEXT - - stub_request(:post, "https://test.dev/v1/chat/completions").to_return( - status: 200, - body: payload, - ) - - response = [] - llm.generate("say hello", user: Discourse.system_user) { |partial| response << partial } - - expect(response.join).to eq( - "Hello Sam. It's nice to meet you. Is there something I can help you with or would you like to chat?", - ) - - log = AiApiAuditLog.order(:id).last - expect(log.request_tokens).to eq(46) - expect(log.response_tokens).to eq(26) - end - - describe "#perform_completion!" do - context "when using regular mode" do - context "with tools" do - it "returns a function invocation" do - compliance.regular_mode_tools(vllm_mock) - end - end - end - - describe "when using streaming mode" do - context "with simple prompts" do - it "completes a trivial prompt and logs the response" do - compliance.streaming_mode_simple_prompt(vllm_mock) - end - end - - context "with tools" do - it "returns a function invoncation" do - compliance.streaming_mode_tools(vllm_mock) - end - end - end - end -end diff --git a/spec/lib/completions/json_stream_decoder_spec.rb b/spec/lib/completions/json_stream_decoder_spec.rb deleted file mode 100644 index 5f0a92ff..00000000 --- a/spec/lib/completions/json_stream_decoder_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Completions::JsonStreamDecoder do - let(:decoder) { DiscourseAi::Completions::JsonStreamDecoder.new } - - before { enable_current_plugin } - - it "should be able to parse simple messages" do - result = decoder << "data: #{{ hello: "world" }.to_json}" - expect(result).to eq([{ hello: "world" }]) - end - - it "should handle anthropic mixed stlye streams" do - stream = (<<~TEXT).split("|") - event: |message_start| - data: |{"hel|lo": "world"}| - - event: |message_start - data: {"foo": "bar"} - - event: |message_start - data: {"ba|z": "qux"|} - - [DONE] - TEXT - - results = [] - stream.each { |chunk| results << (decoder << chunk) } - - expect(results.flatten.compact).to eq([{ hello: "world" }, { foo: "bar" }, { baz: "qux" }]) - end - - it "should be able to handle complex overlaps" do - stream = (<<~TEXT).split("|") - data: |{"hel|lo": "world"} - - data: {"foo": "bar"} - - data: {"ba|z": "qux"|} - - [DONE] - TEXT - - results = [] - stream.each { |chunk| results << (decoder << chunk) } - - expect(results.flatten.compact).to eq([{ hello: "world" }, { foo: "bar" }, { baz: "qux" }]) - end -end diff --git a/spec/lib/completions/llm_spec.rb b/spec/lib/completions/llm_spec.rb deleted file mode 100644 index ccc2c888..00000000 --- a/spec/lib/completions/llm_spec.rb +++ /dev/null @@ -1,195 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::Llm do - subject(:llm) do - described_class.new( - DiscourseAi::Completions::Dialects::OpenAiCompatible, - canned_response, - model, - gateway: canned_response, - ) - end - - fab!(:user) - fab!(:model) { Fabricate(:llm_model) } - - before { enable_current_plugin } - - describe ".proxy" do - it "raises an exception when we can't proxy the model" do - fake_model = "unknown:unknown_v2" - - expect { described_class.proxy(fake_model) }.to( - raise_error(DiscourseAi::Completions::Llm::UNKNOWN_MODEL), - ) - end - end - - describe "AiApiAuditLog" do - it "is able to keep track of post and topic id" do - prompt = - DiscourseAi::Completions::Prompt.new( - "You are fake", - messages: [{ type: :user, content: "fake orders" }], - topic_id: 123, - post_id: 1, - ) - - result = <<~TEXT - data: {"id":"chatcmpl-8xoPOYRmiuBANTmGqdCGVk4ZA3Orz","object":"chat.completion.chunk","created":1709265814,"model":"gpt-4-0125-preview","system_fingerprint":"fp_70b2088885","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]} - - data: {"id":"chatcmpl-8xoPOYRmiuBANTmGqdCGVk4ZA3Orz","object":"chat.completion.chunk","created":1709265814,"model":"gpt-4-0125-preview","system_fingerprint":"fp_70b2088885","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}]} - - data: [DONE] - TEXT - - WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - status: 200, - body: result, - ) - result = +"" - described_class - .proxy("custom:#{model.id}") - .generate(prompt, user: user) { |partial| result << partial } - - expect(result).to eq("Hello") - log = AiApiAuditLog.order("id desc").first - expect(log.topic_id).to eq(123) - expect(log.post_id).to eq(1) - end - - it "can track feature_name and feature_context" do - body = { - model: "gpt-3.5-turbo-0301", - usage: { - prompt_tokens: 337, - completion_tokens: 162, - total_tokens: 499, - }, - choices: [ - { message: { role: "assistant", content: "test" }, finish_reason: "stop", index: 0 }, - ], - }.to_json - - WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - status: 200, - body: body, - ) - - result = - described_class.proxy("custom:#{model.id}").generate( - "Hello", - user: user, - feature_name: "llm_triage", - feature_context: { - foo: "bar", - }, - ) - - expect(result).to eq("test") - log = AiApiAuditLog.order("id desc").first - expect(log.feature_name).to eq("llm_triage") - expect(log.feature_context).to eq({ "foo" => "bar" }) - end - end - - describe "#generate with fake model" do - fab!(:fake_model) - - before do - DiscourseAi::Completions::Endpoints::Fake.delays = [] - DiscourseAi::Completions::Endpoints::Fake.chunk_count = 10 - end - - let(:llm) { described_class.proxy("custom:#{fake_model.id}") } - - let(:prompt) do - DiscourseAi::Completions::Prompt.new( - "You are fake", - messages: [{ type: :user, content: "fake orders" }], - ) - end - - it "can generate a response" do - response = llm.generate(prompt, user: user) - expect(response).to be_present - end - - it "can generate content via a block" do - partials = [] - response = llm.generate(prompt, user: user) { |partial| partials << partial } - - expect(partials.length).to eq(10) - expect(response).to eq(DiscourseAi::Completions::Endpoints::Fake.fake_content) - - expect(partials.join).to eq(response) - end - end - - describe "#generate with various style prompts" do - let :canned_response do - DiscourseAi::Completions::Endpoints::CannedResponse.new(["world"]) - end - - it "can generate a response to a simple string" do - response = llm.generate("hello", user: user) - expect(response).to eq("world") - end - - it "can generate a response from an array" do - response = - llm.generate( - [{ type: :system, content: "you are a bot" }, { type: :user, content: "hello" }], - user: user, - ) - expect(response).to eq("world") - end - end - - describe "#generate" do - let(:prompt) do - system_insts = (<<~TEXT).strip - I want you to act as a title generator for written pieces. I will provide you with a text, - and you will generate five attention-grabbing titles. Please keep the title concise and under 20 words, - and ensure that the meaning is maintained. Replies will utilize the language type of the topic. - TEXT - - DiscourseAi::Completions::Prompt - .new(system_insts) - .tap { |a_prompt| a_prompt.push(type: :user, content: (<<~TEXT).strip) } - Here is the text, inside <input></input> XML tags: - <input> - To perfect his horror, Caesar, surrounded at the base of the statue by the impatient daggers of his friends, - discovers among the faces and blades that of Marcus Brutus, his protege, perhaps his son, and he no longer - defends himself, but instead exclaims: 'You too, my son!' Shakespeare and Quevedo capture the pathetic cry. - </input> - TEXT - end - - let(:canned_response) do - DiscourseAi::Completions::Endpoints::CannedResponse.new( - [ - "<ai>The solitary horse.,The horse etched in gold.,A horse's infinite journey.,A horse lost in time.,A horse's last ride.</ai>", - ], - ) - end - - context "when getting the full response" do - it "processes the prompt and return the response" do - llm_response = llm.generate(prompt, user: user) - - expect(llm_response).to eq(canned_response.responses[0]) - end - end - - context "when getting a streamed response" do - it "processes the prompt and call the given block with the partial response" do - llm_response = +"" - - llm.generate(prompt, user: user) { |partial, cancel_fn| llm_response << partial } - - expect(llm_response).to eq(canned_response.responses[0]) - end - end - end -end diff --git a/spec/lib/completions/prompt_messages_builder_spec.rb b/spec/lib/completions/prompt_messages_builder_spec.rb deleted file mode 100644 index 229b8b13..00000000 --- a/spec/lib/completions/prompt_messages_builder_spec.rb +++ /dev/null @@ -1,590 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Completions::PromptMessagesBuilder do - let(:builder) { DiscourseAi::Completions::PromptMessagesBuilder.new } - fab!(:user) - fab!(:admin) - fab!(:bot_user) { Fabricate(:user) } - fab!(:other_user) { Fabricate(:user) } - - fab!(:image_upload1) do - Fabricate(:upload, user: user, original_filename: "image.png", extension: "png") - end - fab!(:image_upload2) do - Fabricate(:upload, user: user, original_filename: "image.png", extension: "png") - end - - before { enable_current_plugin } - - it "correctly merges user messages with uploads" do - builder.push(type: :user, content: "Hello", id: "Alice", upload_ids: [1]) - builder.push(type: :user, content: "World", id: "Bob", upload_ids: [2]) - - messages = builder.to_a - - # Check the structure of the merged message - expect(messages.length).to eq(1) - expect(messages[0][:type]).to eq(:user) - - # The content should contain the text and both uploads - content = messages[0][:content] - expect(content).to be_an(Array) - expect(content[0]).to eq("Alice: Hello") - expect(content[1]).to eq({ upload_id: 1 }) - expect(content[2]).to eq("\nBob: World") - expect(content[3]).to eq({ upload_id: 2 }) - end - - it "should allow merging user messages" do - builder.push(type: :user, content: "Hello", id: "Alice") - builder.push(type: :user, content: "World", id: "Bob") - - expect(builder.to_a).to eq([{ type: :user, content: "Alice: Hello\nBob: World" }]) - end - - it "should allow adding uploads" do - builder.push(type: :user, content: "Hello", name: "Alice", upload_ids: [1, 2]) - - expect(builder.to_a).to eq( - [{ type: :user, content: ["Hello", { upload_id: 1 }, { upload_id: 2 }], name: "Alice" }], - ) - end - - it "should support function calls" do - builder.push(type: :user, content: "Echo 123 please", name: "Alice") - builder.push(type: :tool_call, content: "echo(123)", name: "echo", id: 1) - builder.push(type: :tool, content: "123", name: "echo", id: 1) - builder.push(type: :user, content: "Hello", name: "Alice") - expected = [ - { type: :user, content: "Echo 123 please", name: "Alice" }, - { type: :tool_call, content: "echo(123)", name: "echo", id: "1" }, - { type: :tool, content: "123", name: "echo", id: "1" }, - { type: :user, content: "Hello", name: "Alice" }, - ] - expect(builder.to_a).to eq(expected) - end - - it "should drop a tool call if it is not followed by tool" do - builder.push(type: :user, content: "Echo 123 please", id: "Alice") - builder.push(type: :tool_call, content: "echo(123)", name: "echo", id: 1) - builder.push(type: :user, content: "OK", id: "James") - - expected = [{ type: :user, content: "Alice: Echo 123 please\nJames: OK" }] - expect(builder.to_a).to eq(expected) - end - - it "should format messages for topic style" do - # Create a topic with tags - topic = Fabricate(:topic, title: "This is an Example Topic") - - # Add tags to the topic - topic.tags = [Fabricate(:tag, name: "tag1"), Fabricate(:tag, name: "tag2")] - topic.save! - - builder.topic = topic - builder.push(type: :user, content: "I like frogs", id: "Bob") - builder.push(type: :user, content: "How do I solve this?", id: "Alice") - - result = builder.to_a(style: :topic) - - content = result[0][:content] - - expect(content).to include("This is an Example Topic") - expect(content).to include("tag1") - expect(content).to include("tag2") - expect(content).to include("Bob: I like frogs") - expect(content).to include("Alice") - expect(content).to include("How do I solve this") - end - - describe "chat context posts in direct messages" do - fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, bot_user]) } - fab!(:dm_message) do - Fabricate( - :chat_message, - chat_channel: dm_channel, - user: user, - message: "I have a question about the topic", - ) - end - - fab!(:topic) { Fabricate(:topic, title: "Important topic for context") } - fab!(:post1) { Fabricate(:post, topic: topic, user: other_user, raw: "This is the first post") } - fab!(:post2) { Fabricate(:post, topic: topic, user: user, raw: "And here's a follow-up") } - - it "correctly includes topic posts as context in direct message channels" do - context = - described_class.messages_from_chat( - dm_message, - channel: dm_channel, - context_post_ids: [post1.id, post2.id], - max_messages: 10, - include_uploads: false, - bot_user_ids: [bot_user.id], - instruction_message: nil, - ) - - expect(context.length).to eq(1) - content = context.first[:content] - - # First part should contain the context intro - expect(content).to include("You are replying inside a Discourse chat") - expect(content).to include( - "This chat is in the context of the Discourse topic 'Important topic for context'", - ) - expect(content).to include(post1.username) - expect(content).to include("This is the first post") - expect(content).to include(post2.username) - expect(content).to include("And here's a follow-up") - - # Last part should have the user's message - expect(content).to include("I have a question about the topic") - end - - it "includes uploads from context posts when include_uploads is true" do - upload = Fabricate(:upload, user: user) - UploadReference.create!(target: post1, upload: upload) - - context = - described_class.messages_from_chat( - dm_message, - channel: dm_channel, - context_post_ids: [post1.id], - max_messages: 10, - include_uploads: true, - bot_user_ids: [bot_user.id], - instruction_message: nil, - ) - - # Verify the upload reference is included - upload_hashes = context.first[:content].select { |item| item.is_a?(Hash) && item[:upload_id] } - expect(upload_hashes).to be_present - expect(upload_hashes.first[:upload_id]).to eq(upload.id) - end - end - - describe ".messages_from_chat" do - fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, bot_user]) } - fab!(:dm_message1) do - Fabricate(:chat_message, chat_channel: dm_channel, user: user, message: "Hello bot") - end - fab!(:dm_message2) do - Fabricate(:chat_message, chat_channel: dm_channel, user: bot_user, message: "Hello human") - end - fab!(:dm_message3) do - Fabricate(:chat_message, chat_channel: dm_channel, user: user, message: "How are you?") - end - - fab!(:public_channel) { Fabricate(:category_channel) } - fab!(:public_message1) do - Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Hello everyone") - end - fab!(:public_message2) do - Fabricate(:chat_message, chat_channel: public_channel, user: bot_user, message: "Hi there") - end - - fab!(:thread_original) do - Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Thread starter") - end - fab!(:thread) do - Fabricate(:chat_thread, channel: public_channel, original_message: thread_original) - end - fab!(:thread_reply1) do - Fabricate( - :chat_message, - chat_channel: public_channel, - user: other_user, - message: "Thread reply", - thread: thread, - ) - end - - fab!(:upload) { Fabricate(:upload, user: user) } - fab!(:message_with_upload) do - Fabricate( - :chat_message, - chat_channel: dm_channel, - user: user, - message: "Check this image", - upload_ids: [upload.id], - ) - end - - it "processes messages from direct message channels" do - context = - described_class.messages_from_chat( - dm_message3, - channel: dm_channel, - context_post_ids: nil, - max_messages: 10, - include_uploads: false, - bot_user_ids: [bot_user.id], - instruction_message: nil, - ) - - # this is all we got cause it is assuming threading - expect(context).to eq([{ type: :user, content: "How are you?", id: user.username }]) - end - - it "includes uploads when include_uploads is true" do - message_with_upload.reload - expect(message_with_upload.uploads).to include(upload) - - context = - described_class.messages_from_chat( - message_with_upload, - channel: dm_channel, - context_post_ids: nil, - max_messages: 10, - include_uploads: true, - bot_user_ids: [bot_user.id], - instruction_message: nil, - ) - - # Find the message with upload - message = - context.find do |m| - m[:content] == - ["Check this image -- uploaded(#{upload.short_url})", { upload_id: upload.id }] - end - expect(message).to be_present - end - - it "doesn't include uploads when include_uploads is false" do - # Make sure the upload is associated with the message - message_with_upload.reload - expect(message_with_upload.uploads).to include(upload) - - context = - described_class.messages_from_chat( - message_with_upload, - channel: dm_channel, - context_post_ids: nil, - max_messages: 10, - include_uploads: false, - bot_user_ids: [bot_user.id], - instruction_message: nil, - ) - - # Find the message with upload - message = - context.find { |m| m[:content] == "Check this image -- uploaded(#{upload.short_url})" } - expect(message).to be_present - expect(message[:upload_ids]).to be_nil - end - - it "properly handles uploads in public channels with multiple users" do - _first_message = - Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "First message") - - _message_with_upload = - Fabricate( - :chat_message, - chat_channel: public_channel, - user: other_user, - message: "Message with image", - upload_ids: [upload.id], - ) - - last_message = - Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Final message") - - context = - described_class.messages_from_chat( - last_message, - channel: public_channel, - context_post_ids: nil, - max_messages: 3, - include_uploads: true, - bot_user_ids: [bot_user.id], - instruction_message: nil, - ) - - expect(context.length).to eq(1) - content = context.first[:content] - - expect(content.length).to eq(3) - expect(content[0]).to include("First message") - expect(content[0]).to include("Message with image") - expect(content[1]).to include({ upload_id: upload.id }) - expect(content[2]).to include("Final message") - end - end - - describe "upload limits in messages_from_chat" do - fab!(:test_channel) { Fabricate(:category_channel) } - fab!(:test_user) { Fabricate(:user) } - - # Create MAX_CHAT_UPLOADS + 1 uploads - fab!(:uploads) do - (described_class::MAX_CHAT_UPLOADS + 1).times.map do |i| - Fabricate(:upload, user: test_user, original_filename: "image#{i}.png", extension: "png") - end - end - - # Create MAX_CHAT_UPLOADS + 1 messages with those uploads - fab!(:messages_with_uploads) do - uploads.map do |upload| - Fabricate( - :chat_message, - chat_channel: test_channel, - user: test_user, - message: "Message with upload #{upload.id}", - ).tap do |msg| - UploadReference.create!(target: msg, upload: upload) - msg.update!(upload_ids: [upload.id]) - end - end - end - - let(:max_uploads) { described_class::MAX_CHAT_UPLOADS } - - it "limits uploads to MAX_CHAT_UPLOADS in the final result" do - last_message = messages_with_uploads.last - - # Make sure uploads are properly associated - messages_with_uploads.each_with_index do |msg, i| - expect(msg.uploads.first.id).to eq(uploads[i].id) - end - - context = - described_class.messages_from_chat( - last_message, - channel: test_channel, - context_post_ids: nil, - max_messages: messages_with_uploads.size, - include_uploads: true, - bot_user_ids: [], - instruction_message: nil, - ) - - # We should have one message containing all message content - expect(context.length).to eq(1) - content = context.first[:content] - - # Count the upload hashes in the content - upload_hashes = content.select { |item| item.is_a?(Hash) && item[:upload_id] } - - # Should have exactly MAX_CHAT_UPLOADS upload references - expect(upload_hashes.size).to eq(max_uploads) - - # The most recent uploads should be preserved (not the oldest) - expected_upload_ids = uploads.last(max_uploads).map(&:id) - actual_upload_ids = upload_hashes.map { |h| h[:upload_id] } - expect(actual_upload_ids).to match_array(expected_upload_ids) - end - end - - describe ".messages_from_post" do - fab!(:pm) do - Fabricate( - :private_message_topic, - title: "This is my special PM", - user: user, - topic_allowed_users: [ - Fabricate.build(:topic_allowed_user, user: user), - Fabricate.build(:topic_allowed_user, user: bot_user), - ], - ) - end - fab!(:first_post) do - Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user") - end - fab!(:second_post) do - Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply") - end - fab!(:third_post) do - Fabricate( - :post, - topic: pm, - user: user, - post_number: 3, - raw: "This is a second reply by the user", - ) - end - - it "provides rich context for for style topic messages" do - freeze_time - - user.update!(trust_level: 2, created_at: 1.year.ago) - admin.update!(trust_level: 4, created_at: 1.month.ago) - user.user_stat.update!(post_count: 10, days_visited: 50) - - reply_to_first_post = - Fabricate( - :post, - topic: pm, - user: admin, - reply_to_post_number: first_post.post_number, - raw: "This is a reply to the first post", - ) - - context = - described_class.messages_from_post( - reply_to_first_post, - style: :topic, - max_posts: 10, - bot_usernames: [bot_user.username], - include_uploads: false, - ) - - expect(context.length).to eq(1) - content = context[0][:content] - - expect(content).to include(user.name) - expect(content).to include("Trust level 2") - expect(content).to include("account age: 1 year") - - # I am mixed on asserting everything cause the test - # will be brittle, but open to changing this - end - - it "handles uploads correctly in topic style messages (and times)" do - freeze_time 1.month.ago - - # Use Discourse's upload format in the post raw content - upload_markdown = "![test|658x372](#{image_upload1.short_url})" - - post_with_upload = - Fabricate( - :post, - topic: pm, - user: admin, - raw: "This is the original #{upload_markdown} I just added", - ) - - UploadReference.create!(target: post_with_upload, upload: image_upload1) - - upload2_markdown = "![test|658x372](#{image_upload2.short_url})" - - freeze_time 1.month.from_now - - post2_with_upload = - Fabricate( - :post, - topic: pm, - user: admin, - raw: "This post has a different image #{upload2_markdown} I just added", - ) - - UploadReference.create!(target: post2_with_upload, upload: image_upload2) - - messages = - described_class.messages_from_post( - post2_with_upload, - style: :topic, - max_posts: 3, - bot_usernames: [bot_user.username], - include_uploads: true, - ) - - # this is not quite ideal yet, images are attached at the end of the post - # long term we may want to extract them out using a regex and create N parts - # so people can talk about multiple images in a single post - # this is the initial ground work though - - expect(messages.length).to eq(1) - content = messages[0][:content] - - # first part - # first image - # second part - # second image - expect(content.length).to eq(4) - expect(content[0]).to include("This is the original") - expect(content[0]).to include("(1 month ago)") - expect(content[1]).to eq({ upload_id: image_upload1.id }) - expect(content[2]).to include("different image") - expect(content[3]).to eq({ upload_id: image_upload2.id }) - end - - context "with limited context" do - it "respects max_context_posts" do - context = - described_class.messages_from_post( - third_post, - max_posts: 1, - bot_usernames: [bot_user.username], - include_uploads: false, - ) - - expect(context).to contain_exactly( - *[{ type: :user, id: user.username, content: third_post.raw }], - ) - end - end - - it "includes previous posts ordered by post_number" do - context = - described_class.messages_from_post( - third_post, - max_posts: 10, - bot_usernames: [bot_user.username], - include_uploads: false, - ) - - expect(context).to eq( - [ - { type: :user, content: "This is a reply by the user", id: user.username }, - { type: :model, content: "This is a bot reply" }, - { type: :user, content: "This is a second reply by the user", id: user.username }, - ], - ) - end - - it "only include regular posts" do - first_post.update!(post_type: Post.types[:whisper]) - - context = - described_class.messages_from_post( - third_post, - max_posts: 10, - bot_usernames: [bot_user.username], - include_uploads: false, - ) - - # skips leading model reply which makes no sense cause first post was whisper - expect(context).to eq( - [{ type: :user, content: "This is a second reply by the user", id: user.username }], - ) - end - - context "with custom prompts" do - it "When post custom prompt is present, we use that instead of the post content" do - custom_prompt = [ - [ - { name: "time", arguments: { name: "time", timezone: "Buenos Aires" } }.to_json, - "time", - "tool_call", - ], - [ - { args: { timezone: "Buenos Aires" }, time: "2023-12-14 17:24:00 -0300" }.to_json, - "time", - "tool", - ], - ["I replied to the time command", bot_user.username], - ] - - PostCustomPrompt.create!(post: second_post, custom_prompt: custom_prompt) - - context = - described_class.messages_from_post( - third_post, - max_posts: 10, - bot_usernames: [bot_user.username], - include_uploads: false, - ) - - expect(context).to eq( - [ - { type: :user, content: "This is a reply by the user", id: user.username }, - { type: :tool_call, content: custom_prompt.first.first, id: "time" }, - { type: :tool, id: "time", content: custom_prompt.second.first }, - { type: :model, content: custom_prompt.third.first }, - { type: :user, content: "This is a second reply by the user", id: user.username }, - ], - ) - end - end - end -end diff --git a/spec/lib/completions/prompt_spec.rb b/spec/lib/completions/prompt_spec.rb deleted file mode 100644 index 4dbd73d2..00000000 --- a/spec/lib/completions/prompt_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::Prompt do - subject(:prompt) { described_class.new(system_insts) } - - let(:system_insts) { "These are the system instructions." } - let(:user_msg) { "Write something nice" } - let(:username) { "username1" } - let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } - - before { enable_current_plugin } - - describe ".new" do - it "raises for invalid attributes" do - expect { described_class.new("a bot", messages: {}) }.to raise_error(ArgumentError) - expect { described_class.new("a bot", tools: {}) }.to raise_error(ArgumentError) - - bad_messages = [{ type: :user, content: "a system message", unknown_attribute: :random }] - expect { described_class.new("a bot", messages: bad_messages) }.to raise_error(ArgumentError) - - bad_messages2 = [{ type: :user }] - expect { described_class.new("a bot", messages: bad_messages2) }.to raise_error(ArgumentError) - - bad_messages3 = [{ content: "some content associated to no one" }] - expect { described_class.new("a bot", messages: bad_messages3) }.to raise_error(ArgumentError) - end - end - - describe "image support" do - it "allows adding uploads inline in messages" do - upload = UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) - - prompt.max_pixels = 300 - prompt.push( - type: :user, - content: ["this is an image", { upload_id: upload.id }, "this was an image"], - ) - - encoded = prompt.content_with_encoded_uploads(prompt.messages.last[:content]) - - expect(encoded.length).to eq(3) - expect(encoded[0]).to eq("this is an image") - expect(encoded[1][:mime_type]).to eq("image/jpeg") - expect(encoded[2]).to eq("this was an image") - end - end - - describe "#push" do - describe "turn validations" do - it "validates that tool messages have a previous tool_call message" do - prompt.push(type: :user, content: user_msg, id: username) - prompt.push(type: :model, content: "I'm a model msg") - - expect { prompt.push(type: :tool, content: "I'm the tool call results") }.to raise_error( - DiscourseAi::Completions::Prompt::INVALID_TURN, - ) - end - - it "validates that model messages have either a previous tool or user messages" do - prompt.push(type: :user, content: user_msg, id: username) - prompt.push(type: :model, content: "I'm a model msg") - - expect { prompt.push(type: :model, content: "I'm a second model msg") }.to raise_error( - DiscourseAi::Completions::Prompt::INVALID_TURN, - ) - end - end - - it "system message is always first" do - prompt.push(type: :user, content: user_msg, id: username) - - system_message = prompt.messages.first - - expect(system_message[:type]).to eq(:system) - expect(system_message[:content]).to eq(system_insts) - end - - it "includes the pushed message" do - prompt.push(type: :user, content: user_msg, id: username) - - system_message = prompt.messages.last - - expect(system_message[:type]).to eq(:user) - expect(system_message[:content]).to eq(user_msg) - expect(system_message[:id]).to eq(username) - end - end -end diff --git a/spec/lib/completions/structured_output_spec.rb b/spec/lib/completions/structured_output_spec.rb deleted file mode 100644 index ef8e687e..00000000 --- a/spec/lib/completions/structured_output_spec.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::StructuredOutput do - subject(:structured_output) do - described_class.new( - { - message: { - type: "string", - }, - bool: { - type: "boolean", - }, - number: { - type: "integer", - }, - status: { - type: "string", - }, - list: { - type: "array", - items: { - type: "string", - }, - }, - }, - ) - end - - before { enable_current_plugin } - - describe "Parsing structured output on the fly" do - it "acts as a buffer for an streamed JSON" do - chunks = [ - +"{\"message\": \"Line 1\\n", - +"Line 2\\n", - +"Line 3\", ", - +"\"bool\": true,", - +"\"number\": 4", - +"2,", - +"\"status\": \"o", - +"\\\"k\\\"\"}", - ] - - structured_output << chunks[0] - expect(structured_output.read_buffered_property(:message)).to eq("Line 1\n") - - structured_output << chunks[1] - expect(structured_output.read_buffered_property(:message)).to eq("Line 2\n") - - structured_output << chunks[2] - expect(structured_output.read_buffered_property(:message)).to eq("Line 3") - - structured_output << chunks[3] - expect(structured_output.read_buffered_property(:bool)).to eq(true) - - # Waiting for number to be fully buffered. - structured_output << chunks[4] - expect(structured_output.read_buffered_property(:bool)).to eq(true) - expect(structured_output.read_buffered_property(:number)).to be_nil - - structured_output << chunks[5] - expect(structured_output.read_buffered_property(:number)).to eq(42) - - structured_output << chunks[6] - expect(structured_output.read_buffered_property(:number)).to eq(42) - expect(structured_output.read_buffered_property(:bool)).to eq(true) - expect(structured_output.read_buffered_property(:status)).to eq("o") - - structured_output << chunks[7] - expect(structured_output.read_buffered_property(:status)).to eq("\"k\"") - - # No partial string left to read. - expect(structured_output.read_buffered_property(:status)).to eq("") - end - - it "supports array types" do - chunks = [ - +"{ \"", - +"list", - +"\":", - +" [\"", - +"Hello!", - +" I am", - +" a ", - +"chunk\",", - +"\"There\"", - +"]}", - ] - - structured_output << chunks[0] - structured_output << chunks[1] - structured_output << chunks[2] - expect(structured_output.read_buffered_property(:list)).to eq(nil) - - structured_output << chunks[3] - expect(structured_output.read_buffered_property(:list)).to eq([""]) - - structured_output << chunks[4] - expect(structured_output.read_buffered_property(:list)).to eq(["Hello!"]) - - structured_output << chunks[5] - structured_output << chunks[6] - structured_output << chunks[7] - - expect(structured_output.read_buffered_property(:list)).to eq(["Hello! I am a chunk"]) - - structured_output << chunks[8] - expect(structured_output.read_buffered_property(:list)).to eq( - ["Hello! I am a chunk", "There"], - ) - - structured_output << chunks[9] - expect(structured_output.read_buffered_property(:list)).to eq( - ["Hello! I am a chunk", "There"], - ) - end - - it "handles empty newline chunks" do - chunks = [+"{\"", +"message", +"\":\"", +"Hello!", +"\n", +"\"", +"}"] - - chunks.each { |c| structured_output << c } - - expect(structured_output.read_buffered_property(:message)).to eq("Hello!\n") - end - end - - describe "dealing with non-JSON responses" do - it "treat it as plain text once we determined it's invalid JSON" do - chunks = [+"I'm not", +"a", +"JSON :)"] - - structured_output << chunks[0] - expect(structured_output.read_buffered_property(:bob)).to eq(nil) - - structured_output << chunks[1] - expect(structured_output.read_buffered_property(:bob)).to eq(nil) - - structured_output << chunks[2] - - structured_output.finish - expect(structured_output.read_buffered_property(:bob)).to eq(nil) - end - - it "can handle broken JSON" do - broken_json = <<~JSON - ```json - { - "message": "This is a broken JSON", - bool: true - } - JSON - - structured_output << broken_json - structured_output.finish - - expect(structured_output.read_buffered_property(:message)).to eq("This is a broken JSON") - expect(structured_output.read_buffered_property(:bool)).to eq(true) - end - end -end diff --git a/spec/lib/completions/tool_definition_spec.rb b/spec/lib/completions/tool_definition_spec.rb deleted file mode 100644 index 005df8ce..00000000 --- a/spec/lib/completions/tool_definition_spec.rb +++ /dev/null @@ -1,340 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::ToolDefinition do - before { enable_current_plugin } - - # Test case 1: Basic tool definition creation - describe "#initialize" do - it "creates a tool with name, description and parameters" do - param = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "query", - description: "The search query", - type: :string, - required: true, - ) - - tool = - described_class.new( - name: "search_engine", - description: "Searches the web for information", - parameters: [param], - ) - - expect(tool.name).to eq("search_engine") - expect(tool.description).to eq("Searches the web for information") - expect(tool.parameters.size).to eq(1) - expect(tool.parameters.first.name).to eq("query") - end - end - - # Test case 2: Creating tool from hash - describe ".from_hash" do - it "creates a tool from a hash representation" do - hash = { - name: "calculator", - description: "Performs math operations", - parameters: [ - { - name: "expression", - description: "Math expression to evaluate", - type: "string", - required: true, - }, - ], - } - - tool = described_class.from_hash(hash) - - expect(tool.name).to eq("calculator") - expect(tool.description).to eq("Performs math operations") - expect(tool.parameters.size).to eq(1) - expect(tool.parameters.first.name).to eq("expression") - expect(tool.parameters.first.type).to eq(:string) - end - - it "rejects a hash with extra keys" do - hash = { - name: "calculator", - description: "Performs math operations", - parameters: [], - extra_key: "should not be here", - } - - expect { described_class.from_hash(hash) }.to raise_error(ArgumentError, /Unexpected keys/) - end - end - - # Test case 3: Parameter with enum validation - describe DiscourseAi::Completions::ToolDefinition::ParameterDefinition do - context "with enum values" do - it "accepts valid enum values matching the type" do - param = - described_class.new( - name: "operation", - description: "Math operation to perform", - type: :string, - enum: %w[add subtract multiply divide], - ) - - expect(param.enum).to eq(%w[add subtract multiply divide]) - end - - it "rejects enum values that don't match the specified type" do - expect { - described_class.new( - name: "operation", - description: "Math operation to perform", - type: :integer, - enum: %w[add subtract], # String values for integer type - ) - }.to raise_error(ArgumentError, /enum values must be integers/) - end - end - - context "with item_type specification" do - it "only allows item_type for array type parameters" do - expect { - described_class.new( - name: "colors", - description: "List of colors", - type: :array, - item_type: :string, - ) - }.not_to raise_error - - expect { - described_class.new( - name: "color", - description: "A single color", - type: :string, - item_type: :string, - ) - }.to raise_error(ArgumentError, /item_type can only be specified for array type/) - end - end - end - - # Test case 4: Coercing string parameters - describe "#coerce_parameters" do - context "with string parameters" do - let(:tool) do - param = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "name", - description: "User's name", - type: :string, - ) - - described_class.new( - name: "greeting", - description: "Generates a greeting", - parameters: [param], - ) - end - - it "converts numbers to strings" do - result = tool.coerce_parameters(name: 123) - expect(result[:name]).to eq("123") - end - - it "converts booleans to strings" do - result = tool.coerce_parameters(name: true) - expect(result[:name]).to eq("true") - end - end - - # Test case 5: Coercing number parameters - context "with number parameters" do - let(:tool) do - param = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "price", - description: "Item price", - type: :number, - ) - - described_class.new(name: "store", description: "Store operations", parameters: [param]) - end - - it "converts string numbers to floats" do - result = tool.coerce_parameters(price: "42.99") - expect(result[:price]).to eq(42.99) - end - - it "converts integers to floats" do - result = tool.coerce_parameters(price: 42) - expect(result[:price]).to eq(42.0) - end - - it "returns nil for invalid number strings" do - result = tool.coerce_parameters(price: "not a number") - expect(result[:price]).to be_nil - end - end - - # Test case 6: Coercing array parameters with item types - context "with array parameters and item types" do - let(:tool) do - param = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "numbers", - description: "List of numeric values", - type: :array, - item_type: :integer, - ) - - described_class.new( - name: "stats", - description: "Statistical operations", - parameters: [param], - ) - end - - it "converts string elements to integers" do - result = tool.coerce_parameters(numbers: %w[1 2 3]) - expect(result[:numbers]).to eq([1, 2, 3]) - end - - it "parses JSON strings into arrays and converts elements" do - result = tool.coerce_parameters(numbers: "[1, 2, 3]") - expect(result[:numbers]).to eq([1, 2, 3]) - end - - it "handles mixed type arrays appropriately" do - result = tool.coerce_parameters(numbers: [1, "two", 3.5]) - expect(result[:numbers]).to eq([1, nil, 3]) - end - end - - # Test case 7: Required parameters - context "with required and optional parameters" do - let(:tool) do - param1 = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "required_param", - description: "This is required", - type: :string, - required: true, - ) - - param2 = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "optional_param", - description: "This is optional", - type: :string, - ) - - described_class.new( - name: "test_tool", - description: "Test tool", - parameters: [param1, param2], - ) - end - - it "includes missing required parameters as nil" do - result = tool.coerce_parameters(optional_param: "value") - expect(result[:required_param]).to be_nil - expect(result[:optional_param]).to eq("value") - end - - it "skips missing optional parameters" do - result = tool.coerce_parameters({}) - expect(result[:required_param]).to be_nil - expect(result.key?("optional_param")).to be false - end - end - - # Test case 8: Boolean parameter coercion - context "with boolean parameters" do - let(:tool) do - param = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "flag", - description: "Boolean flag", - type: :boolean, - ) - - described_class.new(name: "feature", description: "Feature toggle", parameters: [param]) - end - - it "preserves true/false values" do - result = tool.coerce_parameters(flag: true) - expect(result[:flag]).to be true - end - - it "converts 'true'/'false' strings to booleans" do - result = tool.coerce_parameters({ flag: true }) - expect(result[:flag]).to be true - - result = tool.coerce_parameters({ flag: "False" }) - expect(result[:flag]).to be false - end - - it "returns nil for invalid boolean strings" do - result = tool.coerce_parameters({ "flag" => "not a boolean" }) - expect(result["flag"]).to be_nil - end - end - end - - # Test case 9: Duplicate parameter validation - describe "duplicate parameter validation" do - it "rejects tool definitions with duplicate parameter names" do - param1 = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "query", - description: "Search query", - type: :string, - ) - - param2 = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "query", # Same name as param1 - description: "Another parameter", - type: :string, - ) - - expect { - described_class.new( - name: "search", - description: "Search tool", - parameters: [param1, param2], - ) - }.to raise_error(ArgumentError, /Duplicate parameter names/) - end - end - - # Test case 10: Serialization to hash - describe "#to_h" do - it "serializes the tool to a hash with all properties" do - param = - DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new( - name: "colors", - description: "List of colors", - type: :array, - item_type: :string, - required: true, - ) - - tool = - described_class.new( - name: "palette", - description: "Color palette generator", - parameters: [param], - ) - - hash = tool.to_h - - expect(hash[:name]).to eq("palette") - expect(hash[:description]).to eq("Color palette generator") - expect(hash[:parameters].size).to eq(1) - - param_hash = hash[:parameters].first - expect(param_hash[:name]).to eq("colors") - expect(param_hash[:type]).to eq(:array) - expect(param_hash[:item_type]).to eq(:string) - expect(param_hash[:required]).to eq(true) - end - end -end diff --git a/spec/lib/completions/upload_encoder_spec.rb b/spec/lib/completions/upload_encoder_spec.rb deleted file mode 100644 index be1c9f9b..00000000 --- a/spec/lib/completions/upload_encoder_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::UploadEncoder do - let(:gif) { plugin_file_from_fixtures("1x1.gif") } - let(:jpg) { plugin_file_from_fixtures("1x1.jpg") } - let(:webp) { plugin_file_from_fixtures("1x1.webp") } - - before { enable_current_plugin } - - it "automatically converts gifs to pngs" do - upload = UploadCreator.new(gif, "1x1.gif").create_for(Discourse.system_user.id) - encoded = described_class.encode(upload_ids: [upload.id], max_pixels: 1_048_576) - expect(encoded.length).to eq(1) - expect(encoded[0][:base64]).to be_present - expect(encoded[0][:mime_type]).to eq("image/png") - end - - it "automatically converts webp to pngs" do - upload = UploadCreator.new(webp, "1x1.webp").create_for(Discourse.system_user.id) - encoded = described_class.encode(upload_ids: [upload.id], max_pixels: 1_048_576) - expect(encoded.length).to eq(1) - expect(encoded[0][:base64]).to be_present - expect(encoded[0][:mime_type]).to eq("image/png") - end - - it "supports jpg" do - upload = UploadCreator.new(jpg, "1x1.jpg").create_for(Discourse.system_user.id) - encoded = described_class.encode(upload_ids: [upload.id], max_pixels: 1_048_576) - expect(encoded.length).to eq(1) - expect(encoded[0][:base64]).to be_present - expect(encoded[0][:mime_type]).to eq("image/jpeg") - end -end diff --git a/spec/lib/completions/xml_tag_stripper_spec.rb b/spec/lib/completions/xml_tag_stripper_spec.rb deleted file mode 100644 index a7eabef2..00000000 --- a/spec/lib/completions/xml_tag_stripper_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Completions::PromptMessagesBuilder do - let(:tag_stripper) { DiscourseAi::Completions::XmlTagStripper.new(%w[thinking results]) } - - before { enable_current_plugin } - - it "should strip tags correctly in simple cases" do - result = tag_stripper << "x<thinking>hello</thinki" - expect(result).to eq("x") - - result = tag_stripper << "ng>z" - expect(result).to eq("z") - - result = tag_stripper << "king>hello</thinking>" - expect(result).to eq("king>hello</thinking>") - - result = tag_stripper << "123" - expect(result).to eq("123") - end - - it "supports odd nesting" do - text = <<~TEXT - <thinking> - well lets see what happens if I say <results> here... - </thinking> - hello - TEXT - - result = tag_stripper << text - expect(result).to eq("\nhello\n") - end - - it "does not crash when we send a <" do - result = +"" - result << (tag_stripper << "based:\n") - result << (tag_stripper << "<").to_s - result << (tag_stripper << " href") - result << (tag_stripper << ">") - result << (tag_stripper << "test ") - - expect(result).to eq("based:\n< href>test ") - end - - it "strips thinking correctly in a stream" do - result = +"" - result << (tag_stripper << "hello") - result << (tag_stripper << "<").to_s - result << (tag_stripper << "thinking").to_s - result << (tag_stripper << ">").to_s - result << (tag_stripper << "test").to_s - result << (tag_stripper << "<").to_s - result << (tag_stripper << "/").to_s - result << (tag_stripper << "thinking").to_s - result << (tag_stripper << "> world") - - expect(result).to eq("hello world") - end - - it "works when nesting unrelated tags it strips correctly" do - text = <<~TEXT - <thinking> - well lets see what happens if I say <p> here... - </thinking> - abc <b>hello</b> - TEXT - - result = tag_stripper << text - - expect(result).to eq("\nabc <b>hello</b>\n") - end - - it "handles maybe tags correctly" do - result = tag_stripper << "<thinking" - expect(result).to eq(nil) - - expect(tag_stripper.finish).to eq("<thinking") - end -end diff --git a/spec/lib/completions/xml_tool_processor_spec.rb b/spec/lib/completions/xml_tool_processor_spec.rb deleted file mode 100644 index 469ed0d5..00000000 --- a/spec/lib/completions/xml_tool_processor_spec.rb +++ /dev/null @@ -1,277 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Completions::XmlToolProcessor do - let(:processor) { DiscourseAi::Completions::XmlToolProcessor.new } - - before { enable_current_plugin } - - it "can process simple text" do - result = [] - result << (processor << "hello") - result << (processor << " world ") - expect(result).to eq([["hello"], [" world "]]) - expect(processor.finish).to eq([]) - expect(processor.should_cancel?).to eq(false) - end - - it "can handle partial tool calls" do - processor = DiscourseAi::Completions::XmlToolProcessor.new(partial_tool_calls: true) - - xml = (<<~XML).strip - <function|_calls> - <invoke> - <tool_name>h|ell|o<|/tool_name> - <parameters> - <hello>wo|r|ld</hello> - </parameters> - </invoke> - <invoke> - <tool_name>tool|2</tool_name> - <parameters> - <param>v|alue</param> - <para|m2><![CDA|TA[va|lue2]]></param2> - </parame|ters> - </invoke> - XML - - result = [] - - xml.split("|").each { |part| result << (processor << part).map(&:dup) } - - result << (processor.finish) - result.flatten! - - tool1_params = - result - .select do |r| - r.is_a?(DiscourseAi::Completions::ToolCall) && r.name == "hello" && r.partial - end - .map(&:parameters) - - expect(tool1_params).to eq([{ hello: "wo" }, { hello: "wor" }, { hello: "world" }]) - - tool2_params = - result - .select do |r| - r.is_a?(DiscourseAi::Completions::ToolCall) && r.name == "tool2" && r.partial - end - .map(&:parameters) - - expect(tool2_params).to eq( - [ - { param: "v" }, - { param: "value" }, - { param: "value", param2: "va" }, - { param: "value", param2: "value2" }, - ], - ) - end - - it "can handle mix and match xml cause tool llms may not encode" do - xml = (<<~XML).strip - <function_calls> - <invoke> - <tool_name>hello</tool_name> - <parameters> - <hello>world <sam>sam</sam></hello> - <test><![CDATA[</h1>\n</div>\n]]></test> - </parameters> - </invoke> - XML - - result = [] - result << (processor << xml) - result << (processor.finish) - - tool_call = result.last.first - expect(tool_call.parameters).to eq(hello: "world <sam>sam</sam>", test: "</h1>\n</div>\n") - end - - it "is usable for simple single message mode" do - xml = (<<~XML) - world <function_calls> - <invoke> - <tool_name>hello</tool_name> - <parameters> - <hello>world</hello> - <test>value</test> - <bool>true</bool> - </parameters> - </invoke> - XML - - tool_definition = - DiscourseAi::Completions::ToolDefinition.from_hash( - name: "hello", - description: "hello world", - parameters: [ - { name: "hello", type: "string", description: "hello" }, - { name: "test", type: "string", description: "test" }, - { name: "bool", type: "boolean", description: "bool" }, - ], - ) - - processor = DiscourseAi::Completions::XmlToolProcessor.new(tool_definitions: [tool_definition]) - - result = [] - result << (processor << "hello") - result << (processor << xml) - result << (processor.finish) - - tool_call = - DiscourseAi::Completions::ToolCall.new( - id: "tool_0", - name: "hello", - parameters: { - hello: "world", - test: "value", - bool: true, - }, - ) - expect(result).to eq([["hello"], [" world"], [tool_call]]) - expect(processor.should_cancel?).to eq(false) - end - - it "handles multiple tool calls in sequence" do - xml = (<<~XML).strip - start - <function_calls> - <invoke> - <tool_name>first_tool</tool_name> - <parameters> - <param1>value1</param1> - </parameters> - </invoke> - <invoke> - <tool_name>second_tool</tool_name> - <parameters> - <param2>value2</param2> - </parameters> - </invoke> - </function_calls> - end - XML - - result = [] - result << (processor << xml) - result << (processor.finish) - - first_tool = - DiscourseAi::Completions::ToolCall.new( - id: "tool_0", - name: "first_tool", - parameters: { - param1: "value1", - }, - ) - - second_tool = - DiscourseAi::Completions::ToolCall.new( - id: "tool_1", - name: "second_tool", - parameters: { - param2: "value2", - }, - ) - - expect(result).to eq([["start"], [first_tool, second_tool]]) - expect(processor.should_cancel?).to eq(true) - end - - it "handles non-English parameters correctly" do - xml = (<<~XML).strip - こんにちは - <function_calls> - <invoke> - <tool_name>translator</tool_name> - <parameters> - <text>世界</text> - </parameters> - </invoke> - XML - - result = [] - result << (processor << xml) - result << (processor.finish) - - tool_call = - DiscourseAi::Completions::ToolCall.new( - id: "tool_0", - name: "translator", - parameters: { - text: "世界", - }, - ) - - expect(result).to eq([["こんにちは"], [tool_call]]) - end - - it "processes input character by character" do - xml = - "hi<function_calls><invoke><tool_name>test</tool_name><parameters><p>v</p></parameters></invoke>" - - result = [] - xml.each_char { |char| result << (processor << char) } - result << processor.finish - - tool_call = - DiscourseAi::Completions::ToolCall.new(id: "tool_0", name: "test", parameters: { p: "v" }) - - filtered_result = result.reject(&:empty?) - expect(filtered_result).to eq([["h"], ["i"], [tool_call]]) - end - - it "handles malformed XML gracefully" do - xml = (<<~XML).strip - text - <function_calls> - <invoke> - <tool_name>test</tool_name> - <parameters> - <param>value - </parameters> - </invoke> - malformed - XML - - result = [] - result << (processor << xml) - result << (processor.finish) - - # Should just do its best to parse the XML - tool_call = DiscourseAi::Completions::ToolCall.new(id: "tool_0", name: "test", parameters: {}) - expect(result).to eq([["text"], [tool_call]]) - end - - it "correctly processes empty parameter sets" do - xml = (<<~XML).strip - hello - <function_calls> - <invoke> - <tool_name>no_params</tool_name> - <parameters> - </parameters> - </invoke> - XML - - result = [] - result << (processor << xml) - result << (processor.finish) - - tool_call = - DiscourseAi::Completions::ToolCall.new(id: "tool_0", name: "no_params", parameters: {}) - - expect(result).to eq([["hello"], [tool_call]]) - end - - it "properly handles cancelled processing" do - xml = "start<function_calls></function_calls>" - result = [] - result << (processor << xml) - result << (processor << "more text") - result << processor.finish - - expect(result).to eq([["start"], [], []]) - expect(processor.should_cancel?).to eq(true) - end -end diff --git a/spec/lib/discord/bot/persona_replier_spec.rb b/spec/lib/discord/bot/persona_replier_spec.rb deleted file mode 100644 index d1825f8a..00000000 --- a/spec/lib/discord/bot/persona_replier_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Discord::Bot::PersonaReplier do - let(:interaction_body) do - { data: { options: [{ value: "test query" }] }, token: "interaction_token" }.to_json.to_s - end - let(:persona_replier) { described_class.new(interaction_body) } - - fab!(:llm_model) - fab!(:persona) { Fabricate(:ai_persona, default_llm_id: llm_model.id) } - - before do - enable_current_plugin - SiteSetting.ai_discord_search_persona = persona.id.to_s - allow_any_instance_of(DiscourseAi::Personas::Bot).to receive(:reply).and_return( - "This is a reply from bot!", - ) - allow(persona_replier).to receive(:create_reply) - end - - describe "#handle_interaction!" do - it "creates and updates replies" do - persona_replier.handle_interaction! - expect(persona_replier).to have_received(:create_reply).at_least(:once) - end - end -end diff --git a/spec/lib/discord/bot/search_spec.rb b/spec/lib/discord/bot/search_spec.rb deleted file mode 100644 index ce74cb75..00000000 --- a/spec/lib/discord/bot/search_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Discord::Bot::Search do - let(:interaction_body) do - { data: { options: [{ value: "test query" }] }, token: "interaction_token" }.to_json.to_s - end - let(:search) { described_class.new(interaction_body) } - - before do - enable_current_plugin - - stub_request(:post, "https://discord.com/api/webhooks//interaction_token").with( - body: - "{\"content\":\"Here are the top search results for your query:\\n\\n1. [Title](\\u003chttp://test.localhost/link\\u003e)\\n\\n\"}", - ).to_return(status: 200, body: "{}", headers: {}) - - # Stub the create_reply method - allow(search).to receive(:create_reply) - end - - describe "#handle_interaction!" do - it "creates a reply with search results" do - allow_any_instance_of(DiscourseAi::Personas::Tools::Search).to receive(:invoke).and_return( - { rows: [%w[Title /link]] }, - ) - search.handle_interaction! - expect(search).to have_received(:create_reply).with(/Here are the top search results/) - end - end -end diff --git a/spec/lib/discourse_automation/automation_spec.rb b/spec/lib/discourse_automation/automation_spec.rb deleted file mode 100644 index 54717317..00000000 --- a/spec/lib/discourse_automation/automation_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Automation do - before { enable_current_plugin } - - describe "manually configured model" do - let!(:llm_model) { Fabricate(:llm_model) } - it "returns a list of available models for automation" do - models = DiscourseAi::Automation.available_models - expect(models).to be_an(Array) - expect(models.first["translated_name"]).to eq(llm_model.display_name) - end - end - - describe "no models" do - it "returns an empty list" do - models = DiscourseAi::Automation.available_models - expect(models).to be_empty - end - end - - describe "seeded models" do - let!(:llm_model) { Fabricate(:seeded_model) } - it "returns an empty list if no seeded models are allowed" do - models = DiscourseAi::Automation.available_models - expect(models).to be_empty - end - - it "returns a list of seeded models if allowed" do - SiteSetting.ai_automation_allowed_seeded_models = llm_model.id.to_s - models = DiscourseAi::Automation.available_models - expect(models.first["translated_name"]).to eq(llm_model.display_name) - end - end - - describe "mixed models" do - let!(:llm_model) { Fabricate(:llm_model) } - let!(:seeded_model) { Fabricate(:seeded_model) } - - it "returns only the manually configured model if seeded is not allowed" do - models = DiscourseAi::Automation.available_models - expect(models.length).to eq(1) - expect(models.first["translated_name"]).to eq(llm_model.display_name) - end - - it "returns a list of seeded and custom models when seeded is allowed" do - SiteSetting.ai_automation_allowed_seeded_models = seeded_model.id.to_s - models = DiscourseAi::Automation.available_models - - expect(models).to match_array( - [ - { "translated_name" => "#{llm_model.display_name}", "id" => "custom:#{llm_model.id}" }, - { - "translated_name" => "#{seeded_model.display_name}", - "id" => "custom:#{seeded_model.id}", - }, - ], - ) - end - end -end diff --git a/spec/lib/discourse_automation/llm_persona_triage_spec.rb b/spec/lib/discourse_automation/llm_persona_triage_spec.rb deleted file mode 100644 index 57ca6d12..00000000 --- a/spec/lib/discourse_automation/llm_persona_triage_spec.rb +++ /dev/null @@ -1,360 +0,0 @@ -# frozen_string_literal: true - -return if !defined?(DiscourseAutomation) - -describe DiscourseAi::Automation::LlmPersonaTriage do - fab!(:user) - fab!(:bot_user) { Fabricate(:user) } - - fab!(:llm_model) { Fabricate(:anthropic_model, name: "claude-3-opus", enabled_chat_bot: true) } - - fab!(:ai_persona) do - persona = - Fabricate( - :ai_persona, - name: "Triage Helper", - description: "A persona that helps with triaging posts", - system_prompt: "You are a helpful assistant that triages posts", - default_llm: llm_model, - ) - - # Create the user for this persona - persona.update!(user_id: bot_user.id) - persona - end - - let(:automation) do - Fabricate(:automation, name: "my automation", script: "llm_persona_triage", enabled: true) - end - - def add_automation_field(name, value, type: "text") - automation.fields.create!( - component: type, - name: name, - metadata: { - value: value, - }, - target: "script", - ) - end - - before do - enable_current_plugin - - SiteSetting.ai_bot_enabled = true - SiteSetting.ai_bot_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" - - add_automation_field("persona", ai_persona.id, type: "choices") - add_automation_field("whisper", false, type: "boolean") - end - - it "can respond to a post using the specified persona" do - post = Fabricate(:post, raw: "This is a test post that needs triage") - - response_text = "I analyzed your post and can help with that." - - body = (<<~STRING).strip - event: message_start - data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-opus-20240229", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - - event: content_block_start - data: {"type": "content_block_start", "index":0, "content_block": {"type": "text", "text": ""}} - - event: ping - data: {"type": "ping"} - - event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "#{response_text}"}} - - event: content_block_stop - data: {"type": "content_block_stop", "index": 0} - - event: message_delta - data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null, "usage":{"output_tokens": 15}}} - - event: message_stop - data: {"type": "message_stop"} - STRING - - stub_request(:post, "https://api.anthropic.com/v1/messages").to_return(body: body) - - automation.running_in_background! - automation.trigger!({ "post" => post }) - - log = AiApiAuditLog.last - expect(log).to be_present - expect(log.user_id).to eq(post.user_id) - expect(log.feature_name).to eq("automation - #{automation.name}") - - topic = post.topic.reload - last_post = topic.posts.order(:post_number).last - - expect(topic.posts.count).to eq(2) - - # Verify that the response was posted by the persona's user - expect(last_post.user_id).to eq(bot_user.id) - expect(last_post.raw).to eq(response_text) - expect(last_post.post_type).to eq(Post.types[:regular]) # Not a whisper - end - - it "can respond with a whisper when configured to do so" do - add_automation_field("whisper", true, type: "boolean") - post = Fabricate(:post, raw: "This is another test post for triage") - - response_text = "Staff-only response to your post." - - DiscourseAi::Completions::Llm.with_prepared_responses([response_text]) do - automation.running_in_background! - automation.trigger!({ "post" => post }) - end - - topic = post.topic.reload - last_post = topic.posts.order(:post_number).last - - # Verify that the response is a whisper - expect(last_post.user_id).to eq(bot_user.id) - expect(last_post.raw).to eq(response_text) - expect(last_post.post_type).to eq(Post.types[:whisper]) # This should be a whisper - end - - it "does not respond to posts made by bots" do - bot = Fabricate(:bot) - bot_post = Fabricate(:post, user: bot, raw: "This is a bot post") - - # The automation should not trigger for bot posts - DiscourseAi::Completions::Llm.with_prepared_responses(["Response"]) do - automation.running_in_background! - automation.trigger!({ "post" => bot_post }) - end - - # Verify no new post was created - expect(bot_post.topic.reload.posts.count).to eq(1) - end - - it "handles errors gracefully" do - post = Fabricate(:post, raw: "Error-triggering post") - - # Set up to cause an error - ai_persona.update!(user_id: nil) - - # Should not raise an error - expect { - automation.running_in_background! - automation.trigger!({ "post" => post }) - }.not_to raise_error - - # Verify no new post was created - expect(post.topic.reload.posts.count).to eq(1) - end - - it "passes topic metadata in context when responding to topic" do - # Create a category and tags for the test - category = Fabricate(:category, name: "Test Category") - tag1 = Fabricate(:tag, name: "test-tag") - tag2 = Fabricate(:tag, name: "support") - - # Create a topic with category and tags - topic = - Fabricate( - :topic, - title: "Important Question About Feature", - category: category, - tags: [tag1, tag2], - user: user, - ) - - # Create a post in that topic - _post = - Fabricate( - :post, - topic: topic, - user: user, - raw: "This is a test post in a categorized and tagged topic", - ) - - post2 = - Fabricate(:post, topic: topic, user: user, raw: "This is another post in the same topic") - - # Capture the prompt sent to the LLM to verify it contains metadata - prompt = nil - - DiscourseAi::Completions::Llm.with_prepared_responses( - ["I've analyzed your question"], - ) do |_, _, _prompts| - automation.running_in_background! - automation.trigger!({ "post" => post2 }) - prompt = _prompts.first - end - - context = prompt.messages[1][:content] # The second message should be the triage prompt - - # Verify that topic metadata is included in the context - expect(context).to include("Important Question About Feature") - expect(context).to include("Test Category") - expect(context).to include("test-tag") - expect(context).to include("support") - end - - it "interacts correctly with a PM with no replies" do - pm_topic = Fabricate(:private_message_topic, user: user, title: "Important PM") - - # Create initial PM post - pm_post = - Fabricate( - :post, - topic: pm_topic, - user: user, - raw: "This is a private message that needs triage", - ) - - DiscourseAi::Completions::Llm.with_prepared_responses( - ["I've received your private message"], - ) do |_, _, _prompts| - automation.running_in_background! - automation.trigger!({ "post" => pm_post }) - end - - reply = pm_topic.posts.order(:post_number).last - expect(reply.raw).to eq("I've received your private message") - expect(reply.topic.reload.title).to eq("Important PM") - end - - it "interacts correctly with PMs" do - # Create a private message topic - pm_topic = Fabricate(:private_message_topic, user: user, title: "Important PM") - - # Create initial PM post - pm_post = - Fabricate( - :post, - topic: pm_topic, - user: user, - raw: "This is a private message that needs triage", - ) - - # Create a follow-up post - pm_post2 = - Fabricate( - :post, - topic: pm_topic, - user: user, - raw: "Adding more context to my private message", - ) - - # Capture the prompt sent to the LLM - prompt = nil - - original_user_ids = pm_topic.topic_allowed_users.pluck(:user_id) - - DiscourseAi::Completions::Llm.with_prepared_responses( - ["I've received your private message"], - ) do |_, _, _prompts| - automation.running_in_background! - automation.trigger!({ "post" => pm_post2 }) - prompt = _prompts.first - end - - context = prompt.messages[1][:content] - - # Verify that PM metadata is included in the context - expect(context).to include("Important PM") - expect(context).to include(pm_post.raw) - expect(context).to include(pm_post2.raw) - - reply = pm_topic.posts.order(:post_number).last - expect(reply.raw).to eq("I've received your private message") - - topic = reply.topic - - # should not inject persona into allowed users - expect(topic.topic_allowed_users.pluck(:user_id).sort).to eq(original_user_ids.sort) - end - - describe "LLM Persona Triage with Chat Message Creation" do - fab!(:user) - fab!(:bot_user) { Fabricate(:user) } - fab!(:chat_channel) { Fabricate(:category_channel) } - - fab!(:custom_tool) do - AiTool.create!( - name: "Chat Notifier", - tool_name: "chat_notifier", - description: "Creates a chat notification in a channel", - parameters: [ - { name: "channel_id", type: "integer", description: "Chat channel ID" }, - { name: "message", type: "string", description: "Message to post" }, - ], - script: <<~JS, - function invoke(params) { - // Create a chat message using the Chat API - const result = discourse.createChatMessage({ - channel_name: '#{chat_channel.name}', - username: '#{user.username}', - message: params.message - }); - - chain.setCustomRaw("We are done, stopping chaing"); - - return { - success: true, - message_id: result.message_id, - url: result.url, - message: params.message - }; - } - JS - summary: "Notify in chat channel", - created_by: Discourse.system_user, - ) - end - - before do - SiteSetting.chat_enabled = true - - ai_persona.update!(tools: ["custom-#{custom_tool.id}"]) - - # Set up automation fields - automation.fields.create!( - component: "choices", - name: "persona", - metadata: { - value: ai_persona.id, - }, - target: "script", - ) - - automation.fields.create!( - component: "boolean", - name: "silent_mode", - metadata: { - value: true, - }, - target: "script", - ) - end - - it "can silently analyze a post and create a chat notification" do - post = Fabricate(:post, raw: "Please help with my billing issue") - - # Tool response from LLM - tool_call = - DiscourseAi::Completions::ToolCall.new( - name: "chat_notifier", - parameters: { - "message" => "Hello world!", - }, - id: "tool_call_1", - ) - - DiscourseAi::Completions::Llm.with_prepared_responses([tool_call]) do - automation.running_in_background! - automation.trigger!({ "post" => post }) - end - - expect(post.topic.reload.posts.count).to eq(1) - - expect(chat_channel.chat_messages.count).to eq(1) - expect(chat_channel.chat_messages.last.message).to eq("Hello world!") - end - end -end diff --git a/spec/lib/discourse_automation/llm_report_spec.rb b/spec/lib/discourse_automation/llm_report_spec.rb deleted file mode 100644 index f440843a..00000000 --- a/spec/lib/discourse_automation/llm_report_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -return if !defined?(DiscourseAutomation) - -describe DiscourseAutomation do - let(:automation) { Fabricate(:automation, script: "llm_report", enabled: true) } - - fab!(:llm_model) - - fab!(:user) - fab!(:post) - - def add_automation_field(name, value, type: "text") - automation.fields.create!( - component: type, - name: name, - metadata: { - value: value, - }, - target: "script", - ) - end - - before { enable_current_plugin } - - it "can trigger via automation" do - add_automation_field("sender", user.username, type: "user") - add_automation_field("receivers", [user.username], type: "email_group_user") - add_automation_field("model", "custom:#{llm_model.id}") - add_automation_field( - "persona_id", - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - ) - - add_automation_field("title", "Weekly report") - - DiscourseAi::Completions::Llm.with_prepared_responses(["An Amazing Report!!!"]) do - automation.trigger! - end - - pm = Topic.where(title: "Weekly report").first - expect(pm.posts.first.raw).to eq("An Amazing Report!!!") - end - - it "can target a topic" do - add_automation_field("sender", user.username, type: "user") - add_automation_field("topic_id", "#{post.topic_id}") - add_automation_field("model", "custom:#{llm_model.id}") - add_automation_field( - "persona_id", - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - ) - - DiscourseAi::Completions::Llm.with_prepared_responses(["An Amazing Report!!!"]) do - automation.trigger! - end - - expect(post.topic.reload.ordered_posts.last.raw).to eq("An Amazing Report!!!") - end -end diff --git a/spec/lib/discourse_automation/llm_tool_triage_spec.rb b/spec/lib/discourse_automation/llm_tool_triage_spec.rb deleted file mode 100644 index 87175434..00000000 --- a/spec/lib/discourse_automation/llm_tool_triage_spec.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Automation::LlmToolTriage do - fab!(:solver) { Fabricate(:user) } - fab!(:new_user) { Fabricate(:user, trust_level: TrustLevel[0], created_at: 1.day.ago) } - fab!(:topic) { Fabricate(:topic, user: new_user) } - fab!(:post) { Fabricate(:post, topic: topic, user: new_user, raw: "How do I reset my password?") } - fab!(:llm_model) - fab!(:ai_persona) do - persona = Fabricate(:ai_persona, default_llm: llm_model) - persona.create_user - persona - end - - fab!(:tool) do - tool_script = <<~JS - function invoke(params) { - const postId = context.post_id; - const post = discourse.getPost(postId); - const user = discourse.getUser(post.user_id); - - if (user.trust_level > 0) { - return { - processed: false, - reason: "User is not new" - }; - } - - const helper = discourse.getPersona("#{ai_persona.name}"); - const answer = helper.respondTo({ post_id: post.id }); - - return { - answer: answer, - processed: true, - reason: "answered question" - }; - } - JS - - AiTool.create!( - name: "New User Question Answerer", - tool_name: "new_user_question_answerer", - description: "Automatically answers questions from new users when possible", - parameters: [], # No parameters as required by llm_tool_triage - script: tool_script, - created_by_id: Discourse.system_user.id, - summary: "Answers new user questions", - enabled: true, - ) - end - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - it "It is able to answer new user questions" do - result = nil - DiscourseAi::Completions::Llm.with_prepared_responses( - ["this is how you reset your password"], - ) { result = described_class.handle(post: post, tool_id: tool.id) } - expect(result["processed"]).to eq(true) - response = post.topic.reload.posts.order(:post_number).last - expect(response.raw).to eq("this is how you reset your password") - end - - it "Is able to respond as a whisper if instructed" do - # Create a tool with a script that explicitly requests a whisper response - whisper_tool = - AiTool.create!( - name: "Whisper Triage Tool", - tool_name: "whisper_triage_tool", - description: "Responds with whispers to moderation issues", - parameters: [], - script: <<~JS, - function invoke(params) { - const postId = context.post_id; - const post = discourse.getPost(postId); - - const helper = discourse.getPersona("#{ai_persona.name}"); - // Pass instructions to make response a whisper - const answer = helper.respondTo({ - post_id: post.id, - instructions: "Respond as a whisper for moderators only", - whisper: true - }); - - return { - answer: answer, - processed: true, - reason: "responded with whisper" - }; - } - JS - created_by_id: Discourse.system_user.id, - summary: "Responds with whispers", - enabled: true, - ) - - result = nil - DiscourseAi::Completions::Llm.with_prepared_responses( - ["This moderation note is only visible to staff"], - ) { result = described_class.handle(post: post, tool_id: whisper_tool.id) } - - expect(result["processed"]).to eq(true) - response = post.topic.reload.posts.order(:post_number).last - expect(response.raw).to eq("This moderation note is only visible to staff") - # Check that the response is indeed a whisper - expect(response.post_type).to eq(Post.types[:whisper]) - end -end diff --git a/spec/lib/discourse_automation/llm_triage_spec.rb b/spec/lib/discourse_automation/llm_triage_spec.rb deleted file mode 100644 index 32aa822a..00000000 --- a/spec/lib/discourse_automation/llm_triage_spec.rb +++ /dev/null @@ -1,210 +0,0 @@ -# frozen_string_literal: true - -return if !defined?(DiscourseAutomation) - -describe DiscourseAi::Automation::LlmTriage do - fab!(:category) - fab!(:reply_user) { Fabricate(:user) } - fab!(:personal_message) { Fabricate(:private_message_topic) } - let(:canned_reply_text) { "Hello, this is a reply" } - - let(:automation) { Fabricate(:automation, script: "llm_triage", enabled: true) } - - fab!(:llm_model) - - def add_automation_field(name, value, type: "text") - automation.fields.create!( - component: type, - name: name, - metadata: { - value: value, - }, - target: "script", - ) - end - - before do - enable_current_plugin - - SiteSetting.tagging_enabled = true - add_automation_field("system_prompt", "hello %%POST%%") - add_automation_field("search_for_text", "bad") - add_automation_field("model", "custom:#{llm_model.id}") - add_automation_field("category", category.id, type: "category") - add_automation_field("tags", %w[aaa bbb], type: "tags") - add_automation_field("hide_topic", true, type: "boolean") - add_automation_field("flag_post", true, type: "boolean") - add_automation_field("canned_reply", canned_reply_text) - add_automation_field("canned_reply_user", reply_user.username, type: "user") - add_automation_field("max_post_tokens", 100) - end - - it "can trigger via automation" do - post = Fabricate(:post, raw: "hello " * 5000) - - body = { - model: "gpt-3.5-turbo-0301", - usage: { - prompt_tokens: 337, - completion_tokens: 162, - total_tokens: 499, - }, - choices: [ - { message: { role: "assistant", content: "bad" }, finish_reason: "stop", index: 0 }, - ], - }.to_json - - WebMock.stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( - status: 200, - body: body, - ) - - automation.running_in_background! - automation.trigger!({ "post" => post }) - - topic = post.topic.reload - expect(topic.category_id).to eq(category.id) - expect(topic.tags.pluck(:name)).to contain_exactly("aaa", "bbb") - expect(topic.visible).to eq(false) - reply = topic.posts.order(:post_number).last - expect(reply.raw).to eq(canned_reply_text) - expect(reply.user.id).to eq(reply_user.id) - - ai_log = AiApiAuditLog.order("id desc").first - expect(ai_log.feature_name).to eq("llm_triage") - expect(ai_log.feature_context).to eq( - { "automation_id" => automation.id, "automation_name" => automation.name }, - ) - - count = ai_log.raw_request_payload.scan("hello").size - # we could use the exact count here but it can get fragile - # as we change tokenizers, this will give us reasonable confidence - expect(count).to be <= (100) - expect(count).to be > (50) - end - - it "does not triage PMs by default" do - post = Fabricate(:post, topic: personal_message) - automation.running_in_background! - automation.trigger!({ "post" => post }) - - # nothing should happen, no classification, its a PM - end - - it "will triage PMs if automation allows it" do - # needs to be admin or it will not be able to just step in to - # PM - reply_user.update!(admin: true) - add_automation_field("include_personal_messages", true, type: :boolean) - add_automation_field("temperature", "0.2") - add_automation_field("max_output_tokens", "700") - post = Fabricate(:post, topic: personal_message) - - prompt_options = nil - DiscourseAi::Completions::Llm.with_prepared_responses( - ["bad"], - ) do |_resp, _llm, _prompts, _prompt_options| - automation.running_in_background! - automation.trigger!({ "post" => post }) - prompt_options = _prompt_options.first - end - - expect(prompt_options[:temperature]).to eq(0.2) - expect(prompt_options[:max_tokens]).to eq(700) - - last_post = post.topic.reload.posts.order(:post_number).last - expect(last_post.raw).to eq(canned_reply_text) - end - - it "does not reply to the canned_reply_user" do - post = Fabricate(:post, user: reply_user) - - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - automation.running_in_background! - automation.trigger!({ "post" => post }) - end - - last_post = post.topic.reload.posts.order(:post_number).last - expect(last_post.raw).to eq post.raw - end - - it "can respond using an AI persona when configured" do - bot_user = Fabricate(:user, username: "ai_assistant") - ai_persona = - Fabricate( - :ai_persona, - name: "Help Bot", - description: "AI assistant for forum help", - system_prompt: "You are a helpful forum assistant", - default_llm: llm_model, - user_id: bot_user.id, - ) - - # Configure the automation to use the persona instead of canned reply - add_automation_field("canned_reply", nil, type: "message") # Clear canned reply - add_automation_field("reply_persona", ai_persona.id, type: "choices") - add_automation_field("whisper", true, type: "boolean") - - post = Fabricate(:post, raw: "I need help with a problem") - - ai_response = "I'll help you with your problem!" - - # Set up the test to provide both the triage and the persona responses - DiscourseAi::Completions::Llm.with_prepared_responses(["bad", ai_response]) do - automation.running_in_background! - automation.trigger!({ "post" => post }) - end - - # Verify the response was created - topic = post.topic.reload - last_post = topic.posts.order(:post_number).last - - # Verify the AI persona's user created the post - expect(last_post.user_id).to eq(bot_user.id) - - # Verify the content matches the AI response - expect(last_post.raw).to eq(ai_response) - - # Verify it's a whisper post (since we set whisper: true) - expect(last_post.post_type).to eq(Post.types[:whisper]) - end - - it "does not create replies when the action is edit" do - # Set up bot user and persona - bot_user = Fabricate(:user, username: "helper_bot") - ai_persona = - Fabricate( - :ai_persona, - name: "Edit Helper", - description: "AI assistant for editing", - system_prompt: "You help with editing", - default_llm: llm_model, - user_id: bot_user.id, - ) - - # Configure the automation with both reply methods - add_automation_field("canned_reply", "This is a canned reply", type: "message") - add_automation_field("reply_persona", ai_persona.id, type: "choices") - - # Create a post and capture its topic - post = Fabricate(:post, raw: "This needs to be evaluated") - topic = post.topic - - # Get initial post count - initial_post_count = topic.posts.count - - # Run automation with action: :edit and a matching response - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - automation.running_in_background! - automation.trigger!({ "post" => post, "action" => :edit }) - end - - # Topic should be updated (if configured) but no new posts - topic.reload - expect(topic.posts.count).to eq(initial_post_count) - - # Verify no replies were created - last_post = topic.posts.order(:post_number).last - expect(last_post.id).to eq(post.id) - end -end diff --git a/spec/lib/guardian_extensions_spec.rb b/spec/lib/guardian_extensions_spec.rb deleted file mode 100644 index 70cd3207..00000000 --- a/spec/lib/guardian_extensions_spec.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::GuardianExtensions do - fab!(:user) - fab!(:group) - fab!(:topic) - - before do - enable_current_plugin - group.add(user) - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - SiteSetting.ai_summary_gists_enabled = true - end - - let(:anon_guardian) { Guardian.new } - let(:guardian) { Guardian.new(user) } - - describe "#can_see_summary?" do - context "when the user cannot generate a summary" do - before { assign_persona_to(:ai_summarization_persona, []) } - - it "returns false" do - expect(guardian.can_see_summary?(topic)).to eq(false) - end - - it "returns true if there is a cached summary" do - Fabricate(:ai_summary, target: topic) - - expect(guardian.can_see_summary?(topic)).to eq(true) - end - end - - context "when the user can generate a summary" do - before { assign_persona_to(:ai_summarization_persona, [group.id]) } - - it "returns true if the user group is present in the ai_custom_summarization_allowed_groups_map setting" do - expect(guardian.can_see_summary?(topic)).to eq(true) - end - end - - context "when the topic is a PM" do - before { assign_persona_to(:ai_summarization_persona, [group.id]) } - let(:pm) { Fabricate(:private_message_topic) } - - it "returns false" do - expect(guardian.can_see_summary?(pm)).to eq(false) - end - - it "returns true if user is in a group that is allowed summaries" do - SiteSetting.ai_pm_summarization_allowed_groups = group.id - expect(guardian.can_see_summary?(pm)).to eq(true) - end - end - - context "when there is no user" do - it "returns false for anons" do - expect(anon_guardian.can_see_summary?(topic)).to eq(false) - end - - it "returns true for anons when there is a cached summary" do - Fabricate(:ai_summary, target: topic) - - expect(guardian.can_see_summary?(topic)).to eq(true) - end - end - end - - describe "#can_see_gists?" do - before { assign_persona_to(:ai_summary_gists_persona, [group.id]) } - let(:guardian) { Guardian.new(user) } - - context "when access is restricted to the user's group" do - it "returns false when there is a user who is a member of an allowed group" do - expect(guardian.can_see_gists?).to eq(true) - end - - it "returns false for anons" do - expect(anon_guardian.can_see_gists?).to eq(false) - end - - it "returns false for non-group members" do - other_user_guardian = Guardian.new(Fabricate(:user)) - - expect(other_user_guardian.can_see_gists?).to eq(false) - end - end - - context "when access is set to everyone" do - before { assign_persona_to(:ai_summary_gists_persona, [Group::AUTO_GROUPS[:everyone]]) } - - it "returns true" do - expect(guardian.can_see_gists?).to eq(true) - end - - it "returns false for anons" do - expect(anon_guardian.can_see_gists?).to eq(true) - end - end - end -end diff --git a/spec/lib/inference/cloudflare_workers_ai_spec.rb b/spec/lib/inference/cloudflare_workers_ai_spec.rb deleted file mode 100644 index 912ca647..00000000 --- a/spec/lib/inference/cloudflare_workers_ai_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" -require "webmock/rspec" - -RSpec.describe DiscourseAi::Inference::CloudflareWorkersAi do - subject { described_class.new(endpoint, api_token) } - - let(:account_id) { "test_account_id" } - let(:api_token) { "test_api_token" } - let(:model) { "test_model" } - let(:content) { "test content" } - let(:endpoint) do - "https://api.cloudflare.com/client/v4/accounts/#{account_id}/ai/run/@cf/#{model}" - end - let(:headers) do - { - "Referer" => Discourse.base_url, - "Content-Type" => "application/json", - "Authorization" => "Bearer #{api_token}", - } - end - let(:payload) { { text: [content] }.to_json } - - before do - enable_current_plugin - - stub_request(:post, endpoint).with(body: payload, headers: headers).to_return( - status: response_status, - body: response_body, - ) - end - - describe "#perform!" do - context "when the response status is 200" do - let(:response_status) { 200 } - let(:response_body) { { result: { data: ["embedding_result"] } }.to_json } - - it "returns the embedding result" do - result = subject.perform!(content) - expect(result).to eq("embedding_result") - end - end - - context "when the response status is not 200" do - let(:response_status) { 500 } - let(:response_body) { "Internal Server Error" } - - it "raises a Net::HTTPBadResponse error" do - allow(Rails.logger).to receive(:warn) - expect { subject.perform!(content) }.to raise_error(Net::HTTPBadResponse) - expect(Rails.logger).to have_received(:warn).with( - "Cloudflare Workers AI Embeddings failed with status: #{response_status} body: #{response_body}", - ) - end - end - end -end diff --git a/spec/lib/inferred_concepts/applier_spec.rb b/spec/lib/inferred_concepts/applier_spec.rb deleted file mode 100644 index b0eeb952..00000000 --- a/spec/lib/inferred_concepts/applier_spec.rb +++ /dev/null @@ -1,334 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::InferredConcepts::Applier do - subject(:applier) { described_class.new } - - fab!(:topic) { Fabricate(:topic, title: "Ruby Programming Tutorial") } - fab!(:post) { Fabricate(:post, raw: "This post is about advanced testing techniques") } - fab!(:user) { Fabricate(:user, username: "dev_user") } - fab!(:concept1) { Fabricate(:inferred_concept, name: "programming") } - fab!(:concept2) { Fabricate(:inferred_concept, name: "testing") } - fab!(:llm_model) { Fabricate(:fake_model) } - - before do - enable_current_plugin - - SiteSetting.inferred_concepts_match_persona = -1 - SiteSetting.inferred_concepts_enabled = true - - # Set up the post's user - post.update!(user: user) - end - - describe "#apply_to_topic" do - it "does nothing for blank topic or concepts" do - expect { applier.apply_to_topic(nil, [concept1]) }.not_to raise_error - expect { applier.apply_to_topic(topic, []) }.not_to raise_error - expect { applier.apply_to_topic(topic, nil) }.not_to raise_error - end - - it "associates concepts with topic" do - applier.apply_to_topic(topic, [concept1, concept2]) - - expect(topic.inferred_concepts).to include(concept1, concept2) - expect(concept1.topics).to include(topic) - expect(concept2.topics).to include(topic) - end - end - - describe "#apply_to_post" do - it "does nothing for blank post or concepts" do - expect { applier.apply_to_post(nil, [concept1]) }.not_to raise_error - expect { applier.apply_to_post(post, []) }.not_to raise_error - expect { applier.apply_to_post(post, nil) }.not_to raise_error - end - - it "associates concepts with post" do - applier.apply_to_post(post, [concept1, concept2]) - - expect(post.inferred_concepts).to include(concept1, concept2) - expect(concept1.posts).to include(post) - expect(concept2.posts).to include(post) - end - end - - describe "#topic_content_for_analysis" do - it "returns empty string for blank topic" do - expect(applier.topic_content_for_analysis(nil)).to eq("") - end - - it "extracts title and posts content" do - # Create additional posts for the topic - post1 = Fabricate(:post, topic: topic, post_number: 1, raw: "First post content", user: user) - post2 = Fabricate(:post, topic: topic, post_number: 2, raw: "Second post content", user: user) - - content = applier.topic_content_for_analysis(topic) - - expect(content).to include(topic.title) - expect(content).to include("First post content") - expect(content).to include("Second post content") - expect(content).to include(user.username) - expect(content).to include("1)") - expect(content).to include("2)") - end - - it "limits to first 10 posts" do - # Create 12 posts for the topic - 12.times { |i| Fabricate(:post, topic: topic, post_number: i + 1, user: user) } - - allow(Post).to receive(:where).with(topic_id: topic.id).and_call_original - allow_any_instance_of(ActiveRecord::Relation).to receive(:limit).with(10).and_call_original - - applier.topic_content_for_analysis(topic) - - expect(Post).to have_received(:where).with(topic_id: topic.id) - end - end - - describe "#post_content_for_analysis" do - it "returns empty string for blank post" do - expect(applier.post_content_for_analysis(nil)).to eq("") - end - - it "extracts post content with topic context" do - content = applier.post_content_for_analysis(post) - - expect(content).to include(post.topic.title) - expect(content).to include(post.raw) - expect(content).to include(post.user.username) - expect(content).to include("Topic:") - expect(content).to include("Post by") - end - - it "handles post without topic" do - # Mock the post to return nil for topic - allow(post).to receive(:topic).and_return(nil) - - content = applier.post_content_for_analysis(post) - - expect(content).to include(post.raw) - expect(content).to include(post.user.username) - expect(content).to include("Topic: ") - end - end - - describe "#match_existing_concepts" do - let(:manager) { instance_double(DiscourseAi::InferredConcepts::Manager) } - - before do - allow(DiscourseAi::InferredConcepts::Manager).to receive(:new).and_return(manager) - allow(manager).to receive(:list_concepts).and_return(%w[programming testing ruby]) - end - - it "returns empty array for blank topic" do - expect(applier.match_existing_concepts(nil)).to eq([]) - end - - it "returns empty array when no existing concepts" do - allow(manager).to receive(:list_concepts).and_return([]) - - result = applier.match_existing_concepts(topic) - expect(result).to eq([]) - end - - it "matches concepts and applies them to topic" do - # Test the real implementation without stubbing internal methods - allow(InferredConcept).to receive(:where).with(name: ["programming"]).and_return([concept1]) - - # Mock the LLM interaction - persona_instance_double = instance_spy("DiscourseAi::Personas::Persona") - bot_double = instance_spy(DiscourseAi::Personas::Bot) - structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput") - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_match_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(LlmModel).to receive(:find).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_yield( - structured_output_double, - nil, - :structured_output, - ) - allow(structured_output_double).to receive(:read_buffered_property).with( - :matching_concepts, - ).and_return(["programming"]) - - result = applier.match_existing_concepts(topic) - expect(result).to eq([concept1]) - end - end - - describe "#match_existing_concepts_for_post" do - let(:manager) { instance_double(DiscourseAi::InferredConcepts::Manager) } - - before do - allow(DiscourseAi::InferredConcepts::Manager).to receive(:new).and_return(manager) - allow(manager).to receive(:list_concepts).and_return(%w[programming testing ruby]) - end - - it "returns empty array for blank post" do - expect(applier.match_existing_concepts_for_post(nil)).to eq([]) - end - - it "returns empty array when no existing concepts" do - allow(manager).to receive(:list_concepts).and_return([]) - - result = applier.match_existing_concepts_for_post(post) - expect(result).to eq([]) - end - - it "matches concepts and applies them to post" do - # Test the real implementation without stubbing internal methods - allow(InferredConcept).to receive(:where).with(name: ["testing"]).and_return([concept2]) - - # Mock the LLM interaction - persona_instance_double = instance_spy("DiscourseAi::Personas::Persona") - bot_double = instance_spy(DiscourseAi::Personas::Bot) - structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput") - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_match_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(LlmModel).to receive(:find).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_yield( - structured_output_double, - nil, - :structured_output, - ) - allow(structured_output_double).to receive(:read_buffered_property).with( - :matching_concepts, - ).and_return(["testing"]) - - result = applier.match_existing_concepts_for_post(post) - expect(result).to eq([concept2]) - end - end - - describe "#match_concepts_to_content" do - it "returns empty array for blank content or concept list" do - expect(applier.match_concepts_to_content("", ["concept1"])).to eq([]) - expect(applier.match_concepts_to_content(nil, ["concept1"])).to eq([]) - expect(applier.match_concepts_to_content("content", [])).to eq([]) - expect(applier.match_concepts_to_content("content", nil)).to eq([]) - end - - it "uses ConceptMatcher persona to match concepts" do - content = "This is about Ruby programming" - concept_list = %w[programming testing ruby] - structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput") - - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - persona_instance_double = instance_spy("DiscourseAi::Personas::Persona") - bot_double = instance_spy(DiscourseAi::Personas::Bot) - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_match_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(LlmModel).to receive(:find).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_yield( - structured_output_double, - nil, - :structured_output, - ) - allow(structured_output_double).to receive(:read_buffered_property).with( - :matching_concepts, - ).and_return(%w[programming ruby]) - - result = applier.match_concepts_to_content(content, concept_list) - expect(result).to eq(%w[programming ruby]) - - expect(bot_double).to have_received(:reply) - expect(structured_output_double).to have_received(:read_buffered_property).with( - :matching_concepts, - ) - end - - it "handles no structured output gracefully" do - content = "Test content" - concept_list = ["concept1"] - - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - persona_instance_double = instance_double("DiscourseAi::Personas::Persona") - bot_double = instance_double("DiscourseAi::Personas::Bot") - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_match_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(LlmModel).to receive(:find).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_yield(nil, nil, :text) - - result = applier.match_concepts_to_content(content, concept_list) - expect(result).to eq([]) - end - - it "returns empty array when no matching concepts found" do - content = "This is about something else" - concept_list = %w[programming testing] - expected_response = [['{"matching_concepts": []}']] - - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - persona_instance_double = instance_double("DiscourseAi::Personas::Persona") - bot_double = instance_double("DiscourseAi::Personas::Bot") - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_match_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(LlmModel).to receive(:find).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_return(expected_response) - - result = applier.match_concepts_to_content(content, concept_list) - expect(result).to eq([]) - end - - it "handles missing matching_concepts key in response" do - content = "Test content" - concept_list = ["concept1"] - expected_response = [['{"other_key": ["value"]}']] - - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - persona_instance_double = instance_double("DiscourseAi::Personas::Persona") - bot_double = instance_double("DiscourseAi::Personas::Bot") - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_match_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(LlmModel).to receive(:find).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_return(expected_response) - - result = applier.match_concepts_to_content(content, concept_list) - expect(result).to eq([]) - end - end -end diff --git a/spec/lib/inferred_concepts/finder_spec.rb b/spec/lib/inferred_concepts/finder_spec.rb deleted file mode 100644 index 0db814c8..00000000 --- a/spec/lib/inferred_concepts/finder_spec.rb +++ /dev/null @@ -1,282 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::InferredConcepts::Finder do - subject(:finder) { described_class.new } - - fab!(:topic) { Fabricate(:topic, posts_count: 5, views: 200, like_count: 15) } - fab!(:post) { Fabricate(:post, like_count: 10) } - fab!(:concept1) { Fabricate(:inferred_concept, name: "programming") } - fab!(:concept2) { Fabricate(:inferred_concept, name: "testing") } - fab!(:llm_model) { Fabricate(:fake_model) } - - before do - enable_current_plugin - SiteSetting.inferred_concepts_generate_persona = -1 - SiteSetting.inferred_concepts_deduplicate_persona = -1 - SiteSetting.inferred_concepts_enabled = true - end - - describe "#identify_concepts" do - it "returns empty array for blank content" do - expect(finder.identify_concepts("")).to eq([]) - expect(finder.identify_concepts(nil)).to eq([]) - end - - it "uses ConceptFinder persona to identify concepts" do - content = "This is about Ruby programming and testing" - structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput") - - # Mock the persona and bot interaction - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - persona_instance_double = double("PersonaInstance") # rubocop:disable RSpec/VerifiedDoubles - bot_double = instance_double("DiscourseAi::Personas::Bot") - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_generate_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(LlmModel).to receive(:find).with(llm_model.id).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_yield( - structured_output_double, - nil, - :structured_output, - ) - allow(structured_output_double).to receive(:read_buffered_property).with( - :concepts, - ).and_return(%w[ruby programming testing]) - - result = finder.identify_concepts(content) - expect(result).to eq(%w[ruby programming testing]) - end - - it "handles no structured output gracefully" do - content = "Test content" - - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - persona_instance_double = double("PersonaInstance") # rubocop:disable RSpec/VerifiedDoubles - bot_double = instance_double("DiscourseAi::Personas::Bot") - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_generate_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(LlmModel).to receive(:find).with(llm_model.id).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_yield(nil, nil, :text) - - result = finder.identify_concepts(content) - expect(result).to eq([]) - end - end - - describe "#create_or_find_concepts" do - it "returns empty array for blank concept names" do - expect(finder.create_or_find_concepts([])).to eq([]) - expect(finder.create_or_find_concepts(nil)).to eq([]) - end - - it "creates new concepts for new names" do - concept_names = %w[new_concept1 new_concept2] - result = finder.create_or_find_concepts(concept_names) - - expect(result.length).to eq(2) - expect(result.map(&:name)).to match_array(concept_names) - expect(InferredConcept.where(name: concept_names).count).to eq(2) - end - - it "finds existing concepts" do - concept_names = %w[programming testing] - result = finder.create_or_find_concepts(concept_names) - - expect(result.length).to eq(2) - expect(result).to include(concept1, concept2) - end - - it "handles mix of new and existing concepts" do - concept_names = %w[programming new_concept] - result = finder.create_or_find_concepts(concept_names) - - expect(result.length).to eq(2) - expect(result.map(&:name)).to match_array(concept_names) - end - end - - describe "#find_candidate_topics" do - let!(:good_topic) { Fabricate(:topic, posts_count: 6, views: 150, like_count: 12) } - let!(:bad_topic) { Fabricate(:topic, posts_count: 2, views: 50, like_count: 2) } - let!(:topic_with_concepts) do - t = Fabricate(:topic, posts_count: 8, views: 200, like_count: 20) - t.inferred_concepts << concept1 - t - end - - it "finds topics meeting minimum criteria" do - candidates = finder.find_candidate_topics(min_posts: 5, min_views: 100, min_likes: 10) - - expect(candidates).to include(good_topic) - expect(candidates).not_to include(bad_topic) - expect(candidates).not_to include(topic_with_concepts) # already has concepts - end - - it "respects limit parameter" do - candidates = finder.find_candidate_topics(limit: 1) - expect(candidates.length).to be <= 1 - end - - it "excludes specified topic IDs" do - candidates = finder.find_candidate_topics(exclude_topic_ids: [good_topic.id]) - expect(candidates).not_to include(good_topic) - end - - it "filters by category IDs when provided" do - category = Fabricate(:category) - topic_in_category = - Fabricate(:topic, category: category, posts_count: 6, views: 150, like_count: 12) - - candidates = finder.find_candidate_topics(category_ids: [category.id]) - - expect(candidates).to include(topic_in_category) - expect(candidates).not_to include(good_topic) - end - - it "filters by creation date" do - old_topic = - Fabricate(:topic, posts_count: 6, views: 150, like_count: 12, created_at: 45.days.ago) - - candidates = finder.find_candidate_topics(created_after: 30.days.ago) - - expect(candidates).to include(good_topic) - expect(candidates).not_to include(old_topic) - end - end - - describe "#find_candidate_posts" do - let!(:good_post) { Fabricate(:post, like_count: 8, post_number: 2) } - let!(:bad_post) { Fabricate(:post, like_count: 2, post_number: 2) } - let!(:first_post) { Fabricate(:post, like_count: 10, post_number: 1) } - let!(:post_with_concepts) do - p = Fabricate(:post, like_count: 15, post_number: 3) - p.inferred_concepts << concept1 - p - end - - it "finds posts meeting minimum criteria" do - candidates = finder.find_candidate_posts(min_likes: 5) - - expect(candidates).to include(good_post) - expect(candidates).not_to include(bad_post) - expect(candidates).not_to include(post_with_concepts) # already has concepts - end - - it "excludes first posts by default" do - candidates = finder.find_candidate_posts(min_likes: 5) - - expect(candidates).not_to include(first_post) - end - - it "can include first posts when specified" do - candidates = finder.find_candidate_posts(min_likes: 5, exclude_first_posts: false) - - expect(candidates).to include(first_post) - end - - it "respects limit parameter" do - candidates = finder.find_candidate_posts(limit: 1) - expect(candidates.length).to be <= 1 - end - - it "excludes specified post IDs" do - candidates = finder.find_candidate_posts(exclude_post_ids: [good_post.id]) - expect(candidates).not_to include(good_post) - end - - it "filters by category IDs when provided" do - category = Fabricate(:category) - topic_in_category = Fabricate(:topic, category: category) - post_in_category = Fabricate(:post, topic: topic_in_category, like_count: 8, post_number: 2) - - candidates = finder.find_candidate_posts(category_ids: [category.id]) - - expect(candidates).to include(post_in_category) - expect(candidates).not_to include(good_post) - end - - it "filters by creation date" do - old_post = Fabricate(:post, like_count: 8, post_number: 2, created_at: 45.days.ago) - - candidates = finder.find_candidate_posts(created_after: 30.days.ago) - - expect(candidates).to include(good_post) - expect(candidates).not_to include(old_post) - end - end - - describe "#deduplicate_concepts" do - it "returns empty result for blank concept names" do - result = finder.deduplicate_concepts([]) - expect(result).to eq({ deduplicated_concepts: [], mapping: {} }) - - result = finder.deduplicate_concepts(nil) - expect(result).to eq({ deduplicated_concepts: [], mapping: {} }) - end - - it "uses ConceptDeduplicator persona to deduplicate concepts" do - concept_names = ["ruby", "Ruby programming", "testing", "unit testing"] - structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput") - - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - persona_instance_double = double("PersonaInstance") # rubocop:disable RSpec/VerifiedDoubles - bot_double = instance_double("DiscourseAi::Personas::Bot") - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_deduplicate_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(LlmModel).to receive(:find).with(llm_model.id).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_yield( - structured_output_double, - nil, - :structured_output, - ) - allow(structured_output_double).to receive(:read_buffered_property).with( - :streamlined_tags, - ).and_return(%w[ruby testing]) - - result = finder.deduplicate_concepts(concept_names) - expect(result).to eq(%w[ruby testing]) - end - - it "handles no structured output gracefully" do - concept_names = %w[concept1 concept2] - - persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles - persona_instance_double = double("PersonaInstance") # rubocop:disable RSpec/VerifiedDoubles - bot_double = instance_double("DiscourseAi::Personas::Bot") - - allow(AiPersona).to receive(:all_personas).and_return([persona_class_double]) - allow(persona_class_double).to receive(:id).and_return( - SiteSetting.inferred_concepts_deduplicate_persona.to_i, - ) - allow(persona_class_double).to receive(:new).and_return(persona_instance_double) - allow(persona_instance_double).to receive(:class).and_return(persona_class_double) - allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) - allow(LlmModel).to receive(:find).with(llm_model.id).and_return(llm_model) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double) - allow(bot_double).to receive(:reply).and_yield(nil, nil, :text) - - result = finder.deduplicate_concepts(concept_names) - expect(result).to eq([]) - end - end -end diff --git a/spec/lib/inferred_concepts/manager_spec.rb b/spec/lib/inferred_concepts/manager_spec.rb deleted file mode 100644 index 55471004..00000000 --- a/spec/lib/inferred_concepts/manager_spec.rb +++ /dev/null @@ -1,241 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::InferredConcepts::Manager do - subject(:manager) { described_class.new } - - fab!(:topic) - fab!(:post) - fab!(:concept1) { Fabricate(:inferred_concept, name: "programming") } - fab!(:concept2) { Fabricate(:inferred_concept, name: "testing") } - - before { enable_current_plugin } - - describe "#list_concepts" do - it "returns all concepts sorted by name" do - concepts = manager.list_concepts - expect(concepts).to include("programming", "testing") - expect(concepts).to eq(concepts.sort) - end - - it "respects limit parameter" do - concepts = manager.list_concepts(limit: 1) - expect(concepts.length).to eq(1) - end - - it "returns empty array when no concepts exist" do - InferredConcept.destroy_all - concepts = manager.list_concepts - expect(concepts).to eq([]) - end - end - - describe "#generate_concepts_from_content" do - before do - SiteSetting.inferred_concepts_generate_persona = -1 - SiteSetting.inferred_concepts_enabled = true - end - - it "returns empty array for blank content" do - expect(manager.generate_concepts_from_content("")).to eq([]) - expect(manager.generate_concepts_from_content(nil)).to eq([]) - end - - it "delegates to Finder#identify_concepts" do - content = "This is about Ruby programming" - finder = instance_double(DiscourseAi::InferredConcepts::Finder) - allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) - - allow(finder).to receive(:identify_concepts).with(content).and_return(%w[ruby programming]) - - allow(finder).to receive(:create_or_find_concepts).with(%w[ruby programming]).and_return( - [concept1], - ) - - result = manager.generate_concepts_from_content(content) - expect(result).to eq([concept1]) - end - end - - describe "#generate_concepts_from_topic" do - it "returns empty array for blank topic" do - expect(manager.generate_concepts_from_topic(nil)).to eq([]) - end - - it "extracts content and generates concepts" do - applier = instance_double(DiscourseAi::InferredConcepts::Applier) - allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) - allow(applier).to receive(:topic_content_for_analysis).with(topic).and_return("topic content") - - # Mock the finder instead of stubbing subject - finder = instance_double(DiscourseAi::InferredConcepts::Finder) - allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) - allow(finder).to receive(:identify_concepts).with("topic content").and_return(%w[programming]) - allow(finder).to receive(:create_or_find_concepts).with(%w[programming]).and_return( - [concept1], - ) - - result = manager.generate_concepts_from_topic(topic) - expect(result).to eq([concept1]) - end - end - - describe "#generate_concepts_from_post" do - it "returns empty array for blank post" do - expect(manager.generate_concepts_from_post(nil)).to eq([]) - end - - it "extracts content and generates concepts" do - applier = instance_double(DiscourseAi::InferredConcepts::Applier) - allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) - allow(applier).to receive(:post_content_for_analysis).with(post).and_return("post content") - - # Mock the finder instead of stubbing subject - finder = instance_double(DiscourseAi::InferredConcepts::Finder) - allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) - allow(finder).to receive(:identify_concepts).with("post content").and_return(%w[testing]) - allow(finder).to receive(:create_or_find_concepts).with(%w[testing]).and_return([concept1]) - - result = manager.generate_concepts_from_post(post) - expect(result).to eq([concept1]) - end - end - - describe "#match_topic_to_concepts" do - it "returns empty array for blank topic" do - expect(manager.match_topic_to_concepts(nil)).to eq([]) - end - - it "delegates to Applier#match_existing_concepts" do - applier = instance_double(DiscourseAi::InferredConcepts::Applier) - allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) - - allow(applier).to receive(:match_existing_concepts).with(topic).and_return([concept1]) - - result = manager.match_topic_to_concepts(topic) - expect(result).to eq([concept1]) - end - end - - describe "#match_post_to_concepts" do - it "returns empty array for blank post" do - expect(manager.match_post_to_concepts(nil)).to eq([]) - end - - it "delegates to Applier#match_existing_concepts_for_post" do - applier = instance_double(DiscourseAi::InferredConcepts::Applier) - allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) - - allow(applier).to receive(:match_existing_concepts_for_post).with(post).and_return([concept1]) - - result = manager.match_post_to_concepts(post) - expect(result).to eq([concept1]) - end - end - - describe "#search_topics_by_concept" do - it "returns empty array for non-existent concept" do - result = manager.search_topics_by_concept("nonexistent") - expect(result).to eq([]) - end - - it "returns topics associated with concept" do - concept1.topics << topic - result = manager.search_topics_by_concept("programming") - expect(result).to include(topic) - end - end - - describe "#search_posts_by_concept" do - it "returns empty array for non-existent concept" do - result = manager.search_posts_by_concept("nonexistent") - expect(result).to eq([]) - end - - it "returns posts associated with concept" do - concept1.posts << post - result = manager.search_posts_by_concept("programming") - expect(result).to include(post) - end - end - - describe "#match_content_to_concepts" do - it "returns empty array when no concepts exist" do - InferredConcept.destroy_all - result = manager.match_content_to_concepts("some content") - expect(result).to eq([]) - end - - it "delegates to Applier#match_concepts_to_content" do - content = "programming content" - existing_concepts = %w[programming testing] - applier = instance_double(DiscourseAi::InferredConcepts::Applier) - - all_double = instance_double(ActiveRecord::Relation) - allow(InferredConcept).to receive(:all).and_return(all_double) - allow(all_double).to receive(:pluck).with(:name).and_return(existing_concepts) - - allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) - allow(applier).to receive(:match_concepts_to_content).with( - content, - existing_concepts, - ).and_return(["programming"]) - - result = manager.match_content_to_concepts(content) - expect(result).to eq(["programming"]) - end - end - - describe "#find_candidate_topics" do - it "delegates to Finder#find_candidate_topics with options" do - opts = { limit: 50, min_posts: 3 } - finder = instance_double(DiscourseAi::InferredConcepts::Finder) - allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) - - allow(finder).to receive(:find_candidate_topics).with(**opts).and_return([topic]) - - result = manager.find_candidate_topics(opts) - expect(result).to eq([topic]) - end - end - - describe "#find_candidate_posts" do - it "delegates to Finder#find_candidate_posts with options" do - opts = { limit: 25, min_likes: 2 } - finder = instance_double(DiscourseAi::InferredConcepts::Finder) - allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) - - allow(finder).to receive(:find_candidate_posts).with(**opts).and_return([post]) - - result = manager.find_candidate_posts(opts) - expect(result).to eq([post]) - end - end - - describe "#deduplicate_concepts_by_letter" do - before do - # Create test concepts - %w[apple application banana berry cat car dog].each do |name| - Fabricate(:inferred_concept, name: name) - end - end - - it "groups concepts by first letter and deduplicates" do - finder = instance_double(DiscourseAi::InferredConcepts::Finder) - allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) - - allow(finder).to receive(:deduplicate_concepts).at_least(:once).and_return( - %w[apple banana cat dog], - ) - - allow(InferredConcept).to receive(:where).and_call_original - allow(InferredConcept).to receive(:insert_all).and_call_original - - manager.deduplicate_concepts_by_letter - end - - it "handles empty concept list" do - InferredConcept.destroy_all - expect { manager.deduplicate_concepts_by_letter }.not_to raise_error - end - end -end diff --git a/spec/lib/modules/ai_bot/entry_point_spec.rb b/spec/lib/modules/ai_bot/entry_point_spec.rb deleted file mode 100644 index 0ba02bb8..00000000 --- a/spec/lib/modules/ai_bot/entry_point_spec.rb +++ /dev/null @@ -1,190 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::EntryPoint do - before { enable_current_plugin } - - describe "#inject_into" do - describe "subscribes to the post_created event" do - fab!(:admin) - fab!(:bot_allowed_group) { Fabricate(:group) } - - fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") } - let(:gpt_bot) { gpt_4.reload.user } - - fab!(:claude_2) { Fabricate(:llm_model, name: "claude-2") } - - let(:post_args) do - { - title: "Dear AI, I want to ask a question", - raw: "Hello, Can you please tell me a story?", - archetype: Archetype.private_message, - target_usernames: [gpt_bot.username].join(","), - } - end - - before do - toggle_enabled_bots(bots: [gpt_4, claude_2]) - SiteSetting.ai_bot_enabled = true - SiteSetting.ai_bot_allowed_groups = bot_allowed_group.id - bot_allowed_group.add(admin) - end - - it "adds a can_debug_ai_bot_conversations method to current user" do - SiteSetting.ai_bot_debugging_allowed_groups = bot_allowed_group.id.to_s - serializer = CurrentUserSerializer.new(admin, scope: Guardian.new(admin)) - serializer = serializer.as_json - - expect(serializer[:current_user][:can_debug_ai_bot_conversations]).to eq(true) - end - - describe "adding TOPIC_AI_BOT_PM_FIELD to topic custom fields" do - it "is added when user PMs a single bot" do - topic = PostCreator.create!(admin, post_args).topic - expect(topic.reload.custom_fields[DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD]).to eq("t") - end - - it "is not added when user PMs a bot and another user" do - user = Fabricate(:user) - post_args[:target_usernames] = [gpt_bot.username, user.username].join(",") - topic = PostCreator.create!(admin, post_args).topic - expect(topic.reload.custom_fields[DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD]).to be_nil - end - end - - it "adds information about forcing default llm to current_user_serializer" do - Group.refresh_automatic_groups! - - persona = - Fabricate( - :ai_persona, - enabled: true, - allowed_group_ids: [bot_allowed_group.id], - default_llm_id: claude_2.id, - force_default_llm: true, - ) - persona.create_user! - - serializer = CurrentUserSerializer.new(admin, scope: Guardian.new(admin)) - serializer = serializer.as_json - bots = serializer[:current_user][:ai_enabled_chat_bots] - - persona_bot = bots.find { |bot| bot["id"] == persona.user_id } - - expect(persona_bot["username"]).to eq(persona.user.username) - expect(persona_bot["force_default_llm"]).to eq(true) - end - - it "includes user ids for all personas in the serializer" do - Group.refresh_automatic_groups! - - persona = Fabricate(:ai_persona, enabled: true, allowed_group_ids: [bot_allowed_group.id]) - persona.create_user! - - serializer = CurrentUserSerializer.new(admin, scope: Guardian.new(admin)) - serializer = serializer.as_json - bots = serializer[:current_user][:ai_enabled_chat_bots] - - persona_bot = bots.find { |bot| bot["id"] == persona.user_id } - expect(persona_bot["username"]).to eq(persona.user.username) - expect(persona_bot["force_default_llm"]).to eq(false) - end - - it "queues a job to generate a reply by the AI" do - expect { PostCreator.create!(admin, post_args) }.to change( - Jobs::CreateAiReply.jobs, - :size, - ).by(1) - end - - it "does not queue a job for small actions" do - post = PostCreator.create!(admin, post_args) - - expect { - post.topic.add_moderator_post( - admin, - "this is a small action", - post_type: Post.types[:small_action], - ) - }.not_to change(Jobs::CreateAiReply.jobs, :size) - - expect { - post.topic.add_moderator_post( - admin, - "this is a small action", - post_type: Post.types[:moderator_action], - ) - }.not_to change(Jobs::CreateAiReply.jobs, :size) - - expect { - post.topic.add_moderator_post( - admin, - "this is a small action", - post_type: Post.types[:whisper], - ) - }.not_to change(Jobs::CreateAiReply.jobs, :size) - end - - it "includes the bot's user_id" do - claude_bot = DiscourseAi::AiBot::EntryPoint.find_user_from_model("claude-2") - claude_post_attrs = post_args.merge(target_usernames: [claude_bot.username].join(",")) - - expect { PostCreator.create!(admin, claude_post_attrs) }.to change( - Jobs::CreateAiReply.jobs, - :size, - ).by(1) - - job_args = Jobs::CreateAiReply.jobs.last["args"].first - expect(job_args["bot_user_id"]).to eq(claude_bot.id) - end - - context "when the post is not from a PM" do - it "does nothing" do - expect { - PostCreator.create!(admin, post_args.merge(archetype: Archetype.default)) - }.not_to change(Jobs::CreateAiReply.jobs, :size) - end - end - - context "when the bot doesn't have access to the PM" do - it "does nothing" do - user_2 = Fabricate(:user) - expect { - PostCreator.create!(admin, post_args.merge(target_usernames: [user_2.username])) - }.not_to change(Jobs::CreateAiReply.jobs, :size) - end - end - - context "when the user is not allowed to interact with the bot" do - it "does nothing" do - bot_allowed_group.remove(admin) - expect { PostCreator.create!(admin, post_args) }.not_to change( - Jobs::CreateAiReply.jobs, - :size, - ) - end - end - - context "when the post was created by the bot" do - it "does nothing" do - gpt_topic_id = PostCreator.create!(admin, post_args).topic_id - reply_args = - post_args.except(:archetype, :target_usernames, :title).merge(topic_id: gpt_topic_id) - - expect { PostCreator.create!(gpt_bot, reply_args) }.not_to change( - Jobs::CreateAiReply.jobs, - :size, - ) - end - end - end - - it "will include ai_search_discoveries field in the user_option if discover persona is enabled" do - SiteSetting.ai_bot_enabled = true - SiteSetting.ai_bot_discover_persona = Fabricate(:ai_persona).id - - serializer = - CurrentUserSerializer.new(Fabricate(:user), scope: Guardian.new(Fabricate(:user))) - expect(serializer.user_option.ai_search_discoveries).to eq(true) - end - end -end diff --git a/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb b/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb deleted file mode 100644 index fdb0e99c..00000000 --- a/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::CreateAiReply do - fab!(:gpt_35_bot) { Fabricate(:llm_model, name: "gpt-3.5-turbo") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_35_bot]) - end - - describe "#execute" do - fab!(:topic) - fab!(:post) { Fabricate(:post, topic: topic) } - - let(:expected_response) do - "Hello this is a bot and what you just said is an interesting question" - end - - before { SiteSetting.min_personal_message_post_length = 5 } - - it "adds a reply from the bot" do - persona_id = AiPersona.find_by(name: "Forum Helper").id - - bot_user = DiscourseAi::AiBot::EntryPoint.find_user_from_model("gpt-3.5-turbo") - DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do - subject.execute( - post_id: topic.first_post.id, - bot_user_id: bot_user.id, - persona_id: persona_id, - ) - end - - expect(topic.posts.last.raw).to eq(expected_response) - end - end -end diff --git a/spec/lib/modules/ai_bot/playground_spec.rb b/spec/lib/modules/ai_bot/playground_spec.rb deleted file mode 100644 index fe9e0306..00000000 --- a/spec/lib/modules/ai_bot/playground_spec.rb +++ /dev/null @@ -1,1229 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::Playground do - subject(:playground) { described_class.new(bot) } - - fab!(:claude_2) do - Fabricate( - :llm_model, - provider: "anthropic", - url: "https://api.anthropic.com/v1/messages", - name: "claude-2", - ) - end - fab!(:opus_model) { Fabricate(:anthropic_model) } - - fab!(:bot_user) do - enable_current_plugin - toggle_enabled_bots(bots: [claude_2]) - SiteSetting.ai_bot_enabled = true - claude_2.reload.user - end - - fab!(:bot) do - persona = - AiPersona - .find(DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::General]) - .class_instance - .new - DiscourseAi::Personas::Bot.as(bot_user, persona: persona) - end - - fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) } - - fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } - fab!(:pm) do - Fabricate( - :private_message_topic, - title: "This is my special PM", - user: user, - topic_allowed_users: [ - Fabricate.build(:topic_allowed_user, user: user), - Fabricate.build(:topic_allowed_user, user: bot_user), - ], - ) - end - fab!(:first_post) do - Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user") - end - fab!(:second_post) do - Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply") - end - fab!(:third_post) do - Fabricate( - :post, - topic: pm, - user: user, - post_number: 3, - raw: "This is a second reply by the user", - ) - end - - before do - enable_current_plugin - SiteSetting.ai_embeddings_enabled = false - end - - after do - # we must reset cache on persona cause data can be rolled back - AiPersona.persona_cache.flush! - end - - describe "is_bot_user_id?" do - it "properly detects ALL bots as bot users" do - persona = Fabricate(:ai_persona, enabled: false) - persona.create_user! - - expect(DiscourseAi::AiBot::Playground.is_bot_user_id?(persona.user_id)).to eq(true) - end - end - - describe "custom tool integration" do - let!(:custom_tool) do - AiTool.create!( - name: "search", - tool_name: "search", - summary: "searching for things", - description: "A test custom tool", - parameters: [{ name: "query", type: "string", description: "Input for the custom tool" }], - script: - "function invoke(params) { return 'Custom tool result: ' + params.query; }; function details() { return 'did stuff'; }", - created_by: user, - ) - end - - let!(:ai_persona) { Fabricate(:ai_persona, tools: ["custom-#{custom_tool.id}"]) } - let(:tool_call) do - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "666", - parameters: { - query: "Can you use the custom tool", - }, - ) - end - - let(:bot) { DiscourseAi::Personas::Bot.as(bot_user, persona: ai_persona.class_instance.new) } - - let(:playground) { DiscourseAi::AiBot::Playground.new(bot) } - - it "can create uploads from a tool" do - custom_tool.update!(script: <<~JS) - let imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAgEB/awxUE0AAAAASUVORK5CYII=" - function invoke(params) { - let image = upload.create("image.png", imageBase64); - chain.setCustomRaw(`![image](${image.short_url})`); - return image.id; - }; - JS - - tool_name = "custom-#{custom_tool.id}" - ai_persona.update!(tools: [[tool_name, nil, true]], tool_details: false) - - reply_post = nil - prompts = nil - - responses = [tool_call] - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts| - new_post = Fabricate(:post, raw: "Can you use the custom tool?") - reply_post = playground.reply_to(new_post) - prompts = _prompts - end - - expect(prompts.length).to eq(1) - upload_id = prompts[0].messages[3][:content].to_i - - upload = Upload.find(upload_id) - - expect(reply_post.raw).to eq("![image](#{upload.short_url})") - end - - it "can force usage of a tool" do - tool_name = "custom-#{custom_tool.id}" - ai_persona.update!(tools: [[tool_name, nil, true]], forced_tool_count: 1) - responses = [tool_call, ["custom tool did stuff (maybe)"], ["new PM title"]] - - prompts = nil - reply_post = nil - - private_message = Fabricate(:private_message_topic, user: user) - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts| - new_post = Fabricate(:post, raw: "Can you use the custom tool?", topic: private_message) - reply_post = playground.reply_to(new_post) - prompts = _prompts - end - - expect(prompts.length).to eq(3) - expect(prompts[0].tool_choice).to eq("search") - expect(prompts[1].tool_choice).to eq(nil) - - ai_persona.update!(forced_tool_count: 1) - responses = ["no tool call here"] - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts| - new_post = Fabricate(:post, raw: "Will you use the custom tool?", topic: reply_post.topic) - _reply_post = playground.reply_to(new_post) - prompts = _prompts - end - - expect(prompts.length).to eq(1) - expect(prompts[0].tool_choice).to eq(nil) - end - - it "uses custom tool in conversation" do - persona_klass = AiPersona.all_personas.find { |p| p.name == ai_persona.name } - bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona_klass.new) - playground = described_class.new(bot) - - responses = [tool_call, "custom tool did stuff (maybe)"] - - reply_post = nil - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - new_post = Fabricate(:post, raw: "Can you use the custom tool?") - reply_post = playground.reply_to(new_post) - end - - expected = <<~TXT.strip - <details> - <summary>searching for things</summary> - <p>did stuff</p> - </details> - <span></span> - - custom tool did stuff (maybe) - TXT - expect(reply_post.raw).to eq(expected) - - custom_prompt = PostCustomPrompt.find_by(post_id: reply_post.id).custom_prompt - expected_prompt = [ - [ - "{\"arguments\":{\"query\":\"Can you use the custom tool\"}}", - "666", - "tool_call", - "search", - ], - ["\"Custom tool result: Can you use the custom tool\"", "666", "tool", "search"], - ["custom tool did stuff (maybe)", "claude-2"], - ] - - expect(custom_prompt).to eq(expected_prompt) - - custom_tool.update!(enabled: false) - # so we pick up new cache - persona_klass = AiPersona.all_personas.find { |p| p.name == ai_persona.name } - bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona_klass.new) - playground = DiscourseAi::AiBot::Playground.new(bot) - - responses = ["custom tool did stuff (maybe)", tool_call] - - # lets ensure tool does not run... - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompt| - new_post = Fabricate(:post, raw: "Can you use the custom tool?") - reply_post = playground.reply_to(new_post) - end - - expect(reply_post.raw.strip).to eq("custom tool did stuff (maybe)") - end - end - - describe "image support" do - before do - Jobs.run_immediately! - SiteSetting.ai_bot_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" - end - - fab!(:persona) do - AiPersona.create!( - name: "Test Persona", - description: "A test persona", - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - enabled: true, - system_prompt: "You are a helpful bot", - vision_enabled: true, - vision_max_pixels: 1_000, - default_llm_id: opus_model.id, - allow_topic_mentions: true, - ) - end - - fab!(:upload) - - it "sends images to llm" do - post = nil - - persona.create_user! - - image = "![image](upload://#{upload.base62_sha1}.jpg)" - body = "Hey @#{persona.user.username}, can you help me with this image? #{image}" - - prompts = nil - options = nil - DiscourseAi::Completions::Llm.with_prepared_responses( - ["I understood image"], - ) do |_, _, inner_prompts, inner_options| - options = inner_options - post = create_post(title: "some new topic I created", raw: body) - - prompts = inner_prompts - end - - expect(options[0][:feature_name]).to eq("bot") - - content = prompts[0].messages[1][:content] - - expect(content).to include({ upload_id: upload.id }) - - expect(prompts[0].max_pixels).to eq(1000) - - post.topic.reload - last_post = post.topic.posts.order(:post_number).last - - expect(last_post.raw).to eq("I understood image") - end - end - - describe "persona with user support" do - before do - Jobs.run_immediately! - SiteSetting.ai_bot_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" - end - - fab!(:persona) do - persona = - AiPersona.create!( - name: "Test Persona", - description: "A test persona", - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - enabled: true, - system_prompt: "You are a helpful bot", - ) - - persona.create_user! - persona.update!( - default_llm_id: claude_2.id, - allow_chat_channel_mentions: true, - allow_topic_mentions: true, - ) - persona - end - - context "with chat channels" do - fab!(:channel) { Fabricate(:chat_channel) } - - fab!(:membership) do - Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel) - end - - let(:guardian) { Guardian.new(user) } - - before do - SiteSetting.ai_bot_enabled = true - SiteSetting.chat_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" - Group.refresh_automatic_groups! - persona.update!(allow_chat_channel_mentions: true, default_llm_id: opus_model.id) - end - - it "should behave in a sane way when threading is enabled" do - channel.update!(threading_enabled: true) - - message = - ChatSDK::Message.create( - channel_id: channel.id, - raw: "thread 1 message 1", - guardian: guardian, - ) - - message = - ChatSDK::Message.create( - channel_id: channel.id, - raw: "thread 1 message 2", - in_reply_to_id: message.id, - guardian: guardian, - ) - - thread = message.thread - thread.update!(title: "a magic thread") - - message = - ChatSDK::Message.create( - channel_id: channel.id, - raw: "thread 2 message 1", - guardian: guardian, - ) - - message = - ChatSDK::Message.create( - channel_id: channel.id, - raw: "thread 2 message 2", - in_reply_to_id: message.id, - guardian: guardian, - ) - - prompts = nil - DiscourseAi::Completions::Llm.with_prepared_responses([[" ", "world"]]) do |_, _, _prompts| - message = - ChatSDK::Message.create( - channel_id: channel.id, - raw: "Hello @#{persona.user.username}", - guardian: guardian, - ) - - prompts = _prompts - end - - # don't start a thread cause it will get confusing - message.reload - expect(message.thread_id).to be_nil - - prompt = prompts[0] - - content = prompt.messages[1][:content] - # this is fragile by design, mainly so the example can be ultra clear - expected = (<<~TEXT).strip - You are replying inside a Discourse chat channel. Here is a summary of the conversation so far: - {{{ - #{user.username}: (a magic thread) - thread 1 message 1 - #{user.username}: thread 2 message 1 - }}} - - Your instructions: - #{user.username}: Hello - TEXT - - expect(content.strip).to eq(expected) - - reply = Chat::Message.order(:id).last - expect(reply.message).to eq("world") - end - - it "should reply to a mention if properly enabled" do - prompts = nil - - ChatSDK::Message.create( - channel_id: channel.id, - raw: "This is a story about stuff", - guardian: guardian, - ) - - thinking_partial = - DiscourseAi::Completions::Thinking.new( - message: "I should say hello", - signature: "thinking-signature-123", - partial: true, - ) - - thinking = - DiscourseAi::Completions::Thinking.new( - message: "I should say hello", - signature: "thinking-signature-123", - partial: false, - ) - DiscourseAi::Completions::Llm.with_prepared_responses( - [[thinking_partial, thinking, "wo", "rld"]], - ) do |_, _, _prompts| - ChatSDK::Message.create( - channel_id: channel.id, - raw: "Hello @#{persona.user.username}", - guardian: guardian, - ) - - prompts = _prompts - end - - expect(prompts.length).to eq(1) - prompt = prompts[0] - - expect(prompt.messages.length).to eq(2) - expect(prompt.messages[1][:content]).to include("story about stuff") - expect(prompt.messages[1][:content]).to include("Hello") - - last_message = Chat::Message.where(chat_channel_id: channel.id).order("id desc").first - expect(last_message.message).to eq("world") - end - end - - context "with chat dms" do - fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, persona.user]) } - - before do - SiteSetting.chat_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" - Group.refresh_automatic_groups! - persona.update!( - allow_chat_direct_messages: true, - allow_topic_mentions: false, - allow_chat_channel_mentions: false, - default_llm_id: opus_model.id, - ) - SiteSetting.ai_bot_enabled = true - end - - let(:guardian) { Guardian.new(user) } - - it "can supply context" do - post = Fabricate(:post, raw: "this is post content") - - prompts = nil - message = - DiscourseAi::Completions::Llm.with_prepared_responses(["World"]) do |_, _, _prompts| - prompts = _prompts - - ChatSDK::Message.create( - raw: "Hello", - channel_id: dm_channel.id, - context_post_ids: [post.id], - guardian:, - ) - end - - expect(prompts[0].messages[1][:content]).to include("this is post content") - - message.reload - reply = ChatSDK::Thread.messages(thread_id: message.thread_id, guardian: guardian).last - expect(reply.message).to eq("World") - expect(message.thread_id).to be_present - end - - it "can run tools" do - persona.update!(tools: ["Time"]) - - tool_call1 = - DiscourseAi::Completions::ToolCall.new( - name: "time", - id: "time", - parameters: { - timezone: "Buenos Aires", - }, - ) - - tool_call2 = - DiscourseAi::Completions::ToolCall.new( - name: "time", - id: "time", - parameters: { - timezone: "Sydney", - }, - ) - - responses = [[tool_call1, tool_call2], "The time is 2023-12-14 17:24:00 -0300"] - - message = - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - ChatSDK::Message.create(channel_id: dm_channel.id, raw: "Hello", guardian: guardian) - end - - message.reload - expect(message.thread_id).to be_present - reply = ChatSDK::Thread.messages(thread_id: message.thread_id, guardian: guardian).last - - expect(reply.message).to eq("The time is 2023-12-14 17:24:00 -0300") - - # it also needs to have tool details now set on message - prompt = ChatMessageCustomPrompt.find_by(message_id: reply.id) - - expect(prompt.custom_prompt.length).to eq(5) - - # TODO in chat I am mixed on including this in the context, but I guess maybe? - # thinking about this - end - - it "can reply to a chat message" do - message = - DiscourseAi::Completions::Llm.with_prepared_responses(["World"]) do - ChatSDK::Message.create(channel_id: dm_channel.id, raw: "Hello", guardian: guardian) - end - - message.reload - expect(message.thread_id).to be_present - - thread_messages = ChatSDK::Thread.messages(thread_id: message.thread_id, guardian: guardian) - expect(thread_messages.length).to eq(2) - expect(thread_messages.last.message).to eq("World") - - # it also needs to include history per config - first feed some history - persona.update!(enabled: false) - persona_guardian = Guardian.new(persona.user) - - 4.times do |i| - ChatSDK::Message.create( - channel_id: dm_channel.id, - thread_id: message.thread_id, - raw: "request #{i}", - guardian: guardian, - ) - - ChatSDK::Message.create( - channel_id: dm_channel.id, - thread_id: message.thread_id, - raw: "response #{i}", - guardian: persona_guardian, - ) - end - - persona.update!(max_context_posts: 4, enabled: true) - - prompts = nil - DiscourseAi::Completions::Llm.with_prepared_responses( - ["World 2"], - ) do |_response, _llm, _prompts| - ChatSDK::Message.create( - channel_id: dm_channel.id, - thread_id: message.thread_id, - raw: "Hello", - guardian: guardian, - ) - prompts = _prompts - end - - expect(prompts.length).to eq(1) - - mapped = - prompts[0] - .messages - .map { |m| "#{m[:type]}: #{m[:content]}" if m[:type] != :system } - .compact - .join("\n") - .strip - - # why? - # 1. we set context to 4 - # 2. however PromptMessagesBuilder will enforce rules of starting with :user and ending with it - # so one of the model messages is dropped - expected = (<<~TEXT).strip - user: request 3 - model: response 3 - user: Hello - TEXT - - expect(mapped).to eq(expected) - end - end - - it "replies to whispers with a whisper" do - post = nil - DiscourseAi::Completions::Llm.with_prepared_responses(["Yes I can"]) do - post = - create_post( - title: "My public topic", - raw: "Hey @#{persona.user.username}, can you help me?", - post_type: Post.types[:whisper], - ) - end - - post.topic.reload - last_post = post.topic.posts.order(:post_number).last - expect(last_post.raw).to eq("Yes I can") - expect(last_post.user_id).to eq(persona.user_id) - expect(last_post.post_type).to eq(Post.types[:whisper]) - end - - it "allows mentioning a persona" do - # we still should be able to mention with no bots - toggle_enabled_bots(bots: []) - - persona.update!(allow_topic_mentions: true) - - post = nil - DiscourseAi::Completions::Llm.with_prepared_responses(["Yes I can"]) do - post = - create_post( - title: "My public topic", - raw: "Hey @#{persona.user.username}, can you help me?", - ) - end - - post.topic.reload - last_post = post.topic.posts.order(:post_number).last - expect(last_post.raw).to eq("Yes I can") - expect(last_post.user_id).to eq(persona.user_id) - - persona.update!(allow_topic_mentions: false) - - post = - create_post( - title: "My public topic ABC", - raw: "Hey @#{persona.user.username}, can you help me?", - ) - - expect(post.topic.posts.last.post_number).to eq(1) - end - - it "allows swapping a llm mid conversation using a mention" do - SiteSetting.ai_bot_enabled = true - - post = nil - DiscourseAi::Completions::Llm.with_prepared_responses( - ["Yes I can", "Magic Title"], - llm: claude_2, - ) do - post = - create_post( - title: "I just made a PM", - raw: "Hey there #{persona.user.username}, can you help me?", - target_usernames: "#{user.username},#{persona.user.username},#{claude_2.user.username}", - archetype: Archetype.private_message, - user: admin, - ) - end - - # note that this is a string due to custom field shananigans - post.topic.custom_fields["ai_persona_id"] = persona.id.to_s - post.topic.save_custom_fields - - llm2 = Fabricate(:llm_model, enabled_chat_bot: true) - - llm2.toggle_companion_user - - DiscourseAi::Completions::Llm.with_prepared_responses(["Hi from bot two"], llm: llm2) do - create_post( - user: admin, - raw: "hi @#{llm2.user.username.capitalize} how are you", - topic_id: post.topic_id, - ) - end - - last_post = post.topic.reload.posts.order("id desc").first - expect(last_post.raw).to eq("Hi from bot two") - expect(last_post.user_id).to eq(persona.user_id) - - current_users = last_post.topic.reload.topic_allowed_users.joins(:user).pluck(:username) - expect(current_users).to include(llm2.user.username) - - # subseqent replies should come from the new llm - DiscourseAi::Completions::Llm.with_prepared_responses(["Hi from bot two"], llm: llm2) do - create_post( - user: admin, - raw: "just confirming everything switched", - topic_id: post.topic_id, - ) - end - - last_post = post.topic.reload.posts.order("id desc").first - expect(last_post.raw).to eq("Hi from bot two") - expect(last_post.user_id).to eq(persona.user_id) - - # tether llm, so it can no longer be switched - persona.update!(force_default_llm: true, default_llm_id: claude_2.id) - - DiscourseAi::Completions::Llm.with_prepared_responses(["Hi from bot one"], llm: claude_2) do - create_post( - user: admin, - raw: "hi @#{llm2.user.username.capitalize} how are you", - topic_id: post.topic_id, - ) - end - - last_post = post.topic.reload.posts.order("id desc").first - expect(last_post.raw).to eq("Hi from bot one") - expect(last_post.user_id).to eq(persona.user_id) - end - - it "allows PMing a persona even when no particular bots are enabled" do - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: []) - post = nil - - DiscourseAi::Completions::Llm.with_prepared_responses( - ["Yes I can", "Magic Title"], - llm: claude_2, - ) do - post = - create_post( - title: "I just made a PM", - raw: "Hey there #{persona.user.username}, can you help me?", - target_usernames: "#{user.username},#{persona.user.username}", - archetype: Archetype.private_message, - user: admin, - ) - end - - last_post = post.topic.posts.order(:post_number).last - expect(last_post.raw).to eq("Yes I can") - expect(last_post.user_id).to eq(persona.user_id) - - last_post.topic.reload - expect(last_post.topic.allowed_users.pluck(:user_id)).to include(persona.user_id) - - expect(last_post.topic.participant_count).to eq(2) - - # ensure it can be disabled - persona.update!(allow_personal_messages: false) - - post = - create_post( - raw: "Hey there #{persona.user.username}, can you help me please", - topic_id: post.topic.id, - user: admin, - ) - - expect(post.post_number).to eq(3) - end - - it "can tether a persona unconditionally to an llm" do - gpt_35_turbo = Fabricate(:llm_model, name: "gpt-3.5-turbo") - - # If you start a PM with GPT 3.5 bot, replies should come from it, not from Claude - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_35_turbo, claude_2]) - - post = nil - persona.update!(force_default_llm: true, default_llm_id: gpt_35_turbo.id) - - DiscourseAi::Completions::Llm.with_prepared_responses( - ["Yes I can", "Magic Title"], - llm: gpt_35_turbo, - ) do - post = - create_post( - title: "I just made a PM", - raw: "hello world", - target_usernames: "#{user.username},#{claude_2.user.username}", - archetype: Archetype.private_message, - user: admin, - custom_fields: { - "ai_persona_id" => persona.id, - }, - ) - end - - last_post = post.topic.posts.order(:post_number).last - expect(last_post.raw).to eq("Yes I can") - expect(last_post.user_id).to eq(persona.user_id) - - expect(last_post.custom_fields[DiscourseAi::AiBot::POST_AI_LLM_NAME_FIELD]).to eq( - gpt_35_turbo.display_name, - ) - end - - it "picks the correct llm for persona in PMs" do - gpt_35_turbo = Fabricate(:llm_model, name: "gpt-3.5-turbo") - - # If you start a PM with GPT 3.5 bot, replies should come from it, not from Claude - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_35_turbo, claude_2]) - - post = nil - gpt3_5_bot_user = gpt_35_turbo.reload.user - messages = nil - - DiscourseAi::Completions::Llm.with_prepared_responses( - ["Yes I can", "Magic Title"], - llm: gpt_35_turbo, - ) do - messages = - MessageBus.track_publish do - post = - create_post( - title: "I just made a PM", - raw: "Hey @#{persona.user.username}, can you help me?", - target_usernames: "#{user.username},#{gpt3_5_bot_user.username}", - archetype: Archetype.private_message, - user: admin, - ) - end - end - - title_update_message = - messages.find { |m| m.channel == "/discourse-ai/ai-bot/topic/#{post.topic.id}" } - - expect(title_update_message.data).to eq({ title: "Magic Title" }) - last_post = post.topic.posts.order(:post_number).last - expect(last_post.raw).to eq("Yes I can") - expect(last_post.user_id).to eq(persona.user_id) - - last_post.topic.reload - expect(last_post.topic.allowed_users.pluck(:user_id)).to include(persona.user_id) - - # does not reply if replying directly to a user - # nothing is mocked, so this would result in HTTP error - # if we were going to reply - create_post( - raw: "Please ignore this bot, I am replying to a user", - topic: post.topic, - user: admin, - reply_to_post_number: post.post_number, - ) - - # replies as correct persona if replying direct to persona - DiscourseAi::Completions::Llm.with_prepared_responses(["Another reply"], llm: gpt_35_turbo) do - create_post( - raw: "Please ignore this bot, I am replying to a user", - topic: post.topic, - user: admin, - reply_to_post_number: last_post.post_number, - ) - end - - last_post = post.topic.posts.order(:post_number).last - expect(last_post.raw).to eq("Another reply") - expect(last_post.user_id).to eq(persona.user_id) - end - end - - describe "#title_playground" do - let(:expected_response) { "This is a suggested title" } - - before { SiteSetting.min_personal_message_post_length = 5 } - - it "updates the title using bot suggestions" do - DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do - playground.title_playground(third_post, user) - expect(pm.reload.title).to eq(expected_response) - end - end - end - - describe "#reply_to" do - it "preserves thinking context between replies and correctly renders" do - thinking_progress = - DiscourseAi::Completions::Thinking.new(message: "I should say hello", partial: true) - thinking = - DiscourseAi::Completions::Thinking.new( - message: "I should say hello", - signature: "thinking-signature-123", - partial: false, - ) - - thinking_redacted = - DiscourseAi::Completions::Thinking.new( - message: nil, - signature: "thinking-redacted-signature-123", - partial: false, - redacted: true, - ) - - first_responses = [[thinking_progress, thinking, thinking_redacted, "Hello Sam"]] - - DiscourseAi::Completions::Llm.with_prepared_responses(first_responses) do - playground.reply_to(third_post) - end - - new_post = third_post.topic.reload.posts.order(:post_number).last - # confirm message is there - expect(new_post.raw).to include("Hello Sam") - # confirm thinking is there - expect(new_post.raw).to include("I should say hello") - - post = Fabricate(:post, topic: third_post.topic, user: user, raw: "Say Cat") - - prompt_detail = nil - # Capture the prompt to verify thinking context was included - DiscourseAi::Completions::Llm.with_prepared_responses(["Cat"]) do |_, _, prompts| - playground.reply_to(post) - prompt_detail = prompts.first - end - - last_messages = prompt_detail.messages.last(2) - - expect(last_messages).to eq( - [ - { - type: :model, - content: "Hello Sam", - thinking: "I should say hello", - thinking_signature: "thinking-signature-123", - redacted_thinking_signature: "thinking-redacted-signature-123", - }, - { type: :user, content: "Say Cat", id: user.username }, - ], - ) - end - - it "streams the bot reply through MB and create a new post in the PM with a cooked responses" do - expected_bot_response = - "Hello this is a bot and what you just said is an interesting question" - - DiscourseAi::Completions::Llm.with_prepared_responses([expected_bot_response]) do - messages = - MessageBus.track_publish("discourse-ai/ai-bot/topic/#{pm.id}") do - playground.reply_to(third_post) - end - - reply = pm.reload.posts.last - - noop_signal = messages.pop - expect(noop_signal.data[:noop]).to eq(true) - - done_signal = messages.pop - expect(done_signal.data[:done]).to eq(true) - expect(done_signal.data[:cooked]).to eq(reply.cooked) - - expect(messages.first.data[:raw]).to eq("") - - expect(reply.cooked).to eq(PrettyText.cook(expected_bot_response)) - - messages[1..-1].each do |m| - expect(expected_bot_response.start_with?(m.data[:raw])).to eq(true) - end - end - end - - it "supports multiple function calls" do - tool_call1 = - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "search", - parameters: { - search_query: "testing various things", - }, - ) - - tool_call2 = - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "search", - parameters: { - search_query: "another search", - }, - ) - - response2 = "I found stuff" - - DiscourseAi::Completions::Llm.with_prepared_responses( - [[tool_call1, tool_call2], response2], - ) { playground.reply_to(third_post) } - - last_post = third_post.topic.reload.posts.order(:post_number).last - - expect(last_post.raw).to include("testing various things") - expect(last_post.raw).to include("another search") - expect(last_post.raw).to include("I found stuff") - end - - it "supports disabling tool details" do - persona = Fabricate(:ai_persona, tool_details: false, tools: ["Search"]) - bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.class_instance.new) - playground = described_class.new(bot) - - response1 = - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "search", - parameters: { - search_query: "testing various things", - }, - ) - - response2 = "I found stuff" - - DiscourseAi::Completions::Llm.with_prepared_responses([response1, response2]) do - playground.reply_to(third_post) - end - - last_post = third_post.topic.reload.posts.order(:post_number).last - - expect(last_post.raw).to eq("I found stuff") - end - - it "does not include placeholders in conversation context but includes all completions" do - response1 = - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "search", - parameters: { - search_query: "testing various things", - }, - ) - - response2 = "I found some really amazing stuff!" - - DiscourseAi::Completions::Llm.with_prepared_responses([response1, response2]) do - playground.reply_to(third_post) - end - - last_post = third_post.topic.reload.posts.order(:post_number).last - custom_prompt = PostCustomPrompt.where(post_id: last_post.id).first.custom_prompt - - expect(custom_prompt.length).to eq(3) - expect(custom_prompt.to_s).not_to include("<details>") - expect(custom_prompt.last.first).to eq(response2) - expect(custom_prompt.last.last).to eq(bot_user.username) - end - - context "with Dall E bot" do - before { SiteSetting.ai_openai_api_key = "123" } - - let(:persona) do - AiPersona.find( - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::DallE3], - ) - end - - let(:bot) { DiscourseAi::Personas::Bot.as(bot_user, persona: persona.class_instance.new) } - let(:data) do - image = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - - [{ b64_json: image, revised_prompt: "a pink cow 1" }] - end - - let(:response) do - DiscourseAi::Completions::ToolCall.new( - name: "dall_e", - id: "dall_e", - parameters: { - prompts: ["a pink cow"], - }, - ) - end - - it "properly returns an image when skipping tool details" do - persona.update!(tool_details: false) - - WebMock.stub_request(:post, SiteSetting.ai_openai_image_generation_url).to_return( - status: 200, - body: { data: data }.to_json, - ) - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) do - playground.reply_to(third_post) - end - - last_post = third_post.topic.reload.posts.order(:post_number).last - - expect(last_post.raw).to include("a pink cow") - end - - it "does not include placeholders in conversation context (simulate DALL-E)" do - WebMock.stub_request(:post, SiteSetting.ai_openai_image_generation_url).to_return( - status: 200, - body: { data: data }.to_json, - ) - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) do - playground.reply_to(third_post) - end - - last_post = third_post.topic.reload.posts.order(:post_number).last - custom_prompt = PostCustomPrompt.where(post_id: last_post.id).first.custom_prompt - - # DALL E has custom_raw, we do not want to inject this into the prompt stream - expect(custom_prompt.length).to eq(2) - expect(custom_prompt.to_s).not_to include("<details>") - end - end - end - - describe "#canceling a completions" do - after { DiscourseAi::AiBot::PostStreamer.on_callback = nil } - - it "should be able to cancel a completion halfway through" do - body = (<<~STRING).strip - event: message_start - data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-opus-20240229", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - - event: content_block_start - data: {"type": "content_block_start", "index":0, "content_block": {"type": "text", "text": ""}} - - event: ping - data: {"type": "ping"} - - |event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}} - - |event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "1"}} - - |event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "2"}} - - |event: content_block_delta - data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "3"}} - - event: content_block_stop - data: {"type": "content_block_stop", "index": 0} - - event: message_delta - data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null, "usage":{"output_tokens": 15}}} - - event: message_stop - data: {"type": "message_stop"} - STRING - - split = body.split("|") - - cancel_manager = DiscourseAi::Completions::CancelManager.new - - count = 0 - DiscourseAi::AiBot::PostStreamer.on_callback = - proc do |callback| - count += 1 - cancel_manager.cancel! if count == 2 - raise "this should not happen" if count > 2 - end - - require_relative("../../completions/endpoints/endpoint_compliance") - EndpointMock.with_chunk_array_support do - stub_request(:post, "https://api.anthropic.com/v1/messages").to_return( - status: 200, - body: split, - ) - # we are going to need to use real data here cause we want to trigger the - # base endpoint to cancel part way through - playground.reply_to(third_post, cancel_manager: cancel_manager) - end - - last_post = third_post.topic.posts.order(:id).last - - # not Hello123, we cancelled at 1 - expect(last_post.raw).to eq("Hello1") - end - end - - describe "#available_bot_usernames" do - it "includes persona users" do - persona = Fabricate(:ai_persona) - persona.create_user! - - expect(playground.available_bot_usernames).to include(persona.user.username) - end - end - - describe "custom tool context injection" do - let!(:custom_tool) do - AiTool.create!( - name: "context_tool", - tool_name: "context_tool", - summary: "tool with custom context", - description: "A test custom tool that injects context", - parameters: [{ name: "query", type: "string", description: "Input for the custom tool" }], - script: <<~JS, - function invoke(params) { - return 'Custom tool result: ' + params.query; - } - - function customContext() { - return "This is additional context from the tool"; - } - - function details() { - return 'executed with custom context'; - } - JS - created_by: user, - ) - end - - let!(:ai_persona) { Fabricate(:ai_persona, tools: ["custom-#{custom_tool.id}"]) } - let(:bot) { DiscourseAi::Personas::Bot.as(bot_user, persona: ai_persona.class_instance.new) } - let(:playground) { DiscourseAi::AiBot::Playground.new(bot) } - - it "injects custom context into the prompt" do - prompts = nil - response = "I received the additional context" - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) do |_, _, _prompts| - new_post = Fabricate(:post, raw: "Can you use the custom context tool?") - playground.reply_to(new_post) - prompts = _prompts - end - - # The first prompt should have the custom context prepended to the user message - user_message = prompts[0].messages.last - expect(user_message[:content]).to include("This is additional context from the tool") - expect(user_message[:content]).to include("Can you use the custom context tool?") - end - end -end diff --git a/spec/lib/modules/ai_bot/site_setting_extension_spec.rb b/spec/lib/modules/ai_bot/site_setting_extension_spec.rb deleted file mode 100644 index 4b3a05b0..00000000 --- a/spec/lib/modules/ai_bot/site_setting_extension_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -#frozen_string_literal: true - -describe DiscourseAi::AiBot::SiteSettingsExtension do - fab!(:claude_2) { Fabricate(:llm_model, name: "claude-2") } - fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") } - fab!(:gpt_35_turbo) { Fabricate(:llm_model, name: "gpt-3.5-turbo") } - - def user_exists?(model) - DiscourseAi::AiBot::EntryPoint.find_user_from_model(model).present? - end - - before { enable_current_plugin } - - it "correctly creates/deletes bot accounts as needed" do - SiteSetting.ai_bot_enabled = true - gpt_4.update!(enabled_chat_bot: true) - claude_2.update!(enabled_chat_bot: false) - gpt_35_turbo.update!(enabled_chat_bot: false) - - DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots - - expect(user_exists?("gpt-4")).to eq(true) - expect(user_exists?("gpt-3.5-turbo")).to eq(false) - expect(user_exists?("claude-2")).to eq(false) - - gpt_4.update!(enabled_chat_bot: false) - claude_2.update!(enabled_chat_bot: false) - gpt_35_turbo.update!(enabled_chat_bot: true) - - DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots - - expect(user_exists?("gpt-4")).to eq(false) - expect(user_exists?("gpt-3.5-turbo")).to eq(true) - expect(user_exists?("claude-2")).to eq(false) - - gpt_4.update!(enabled_chat_bot: false) - claude_2.update!(enabled_chat_bot: true) - gpt_35_turbo.update!(enabled_chat_bot: true) - - DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots - - expect(user_exists?("gpt-4")).to eq(false) - expect(user_exists?("gpt-3.5-turbo")).to eq(true) - expect(user_exists?("claude-2")).to eq(true) - - SiteSetting.ai_bot_enabled = false - DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots - - expect(user_exists?("gpt-4")).to eq(false) - expect(user_exists?("gpt-3.5-turbo")).to eq(false) - expect(user_exists?("claude-2")).to eq(false) - end - - it "leaves accounts around if they have any posts" do - SiteSetting.ai_bot_enabled = true - gpt_4.update!(enabled_chat_bot: true) - DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots - - user = DiscourseAi::AiBot::EntryPoint.find_user_from_model("gpt-4") - - create_post(user: user, raw: "this is a test post") - - user.reload - SiteSetting.ai_bot_enabled = false - - user.reload - expect(user.active).to eq(false) - - SiteSetting.ai_bot_enabled = true - - user.reload - expect(user.active).to eq(true) - end -end diff --git a/spec/lib/modules/ai_helper/assistant_spec.rb b/spec/lib/modules/ai_helper/assistant_spec.rb deleted file mode 100644 index 40215a5e..00000000 --- a/spec/lib/modules/ai_helper/assistant_spec.rb +++ /dev/null @@ -1,229 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiHelper::Assistant do - fab!(:user) - fab!(:empty_locale_user) { Fabricate(:user, locale: "") } - - before do - enable_current_plugin - assign_fake_provider_to(:ai_helper_model) - Group.refresh_automatic_groups! - end - - let(:english_text) { <<~STRING } - To perfect his horror, Caesar, surrounded at the base of the statue by the impatient daggers of his friends, - discovers among the faces and blades that of Marcus Brutus, his protege, perhaps his son, and he no longer - defends himself, but instead exclaims: 'You too, my son!' Shakespeare and Quevedo capture the pathetic cry. - STRING - - describe("#custom_locale_instructions") do - it "Properly generates the per locale system instruction" do - SiteSetting.default_locale = "ko" - expect(subject.custom_locale_instructions(user, false)).to eq( - "It is imperative that you write your answer in Korean (한국어), you are interacting with a Korean (한국어) speaking user. Leave tag names in English.", - ) - - SiteSetting.allow_user_locale = true - user.update!(locale: "he") - - expect(subject.custom_locale_instructions(user, false)).to eq( - "It is imperative that you write your answer in Hebrew (עברית), you are interacting with a Hebrew (עברית) speaking user. Leave tag names in English.", - ) - end - - it "returns sytstem instructions using Site locale if force_default_locale is true" do - SiteSetting.default_locale = "ko" - SiteSetting.allow_user_locale = true - user.update!(locale: "he") - - expect(subject.custom_locale_instructions(user, true)).to eq( - "It is imperative that you write your answer in Korean (한국어), you are interacting with a Korean (한국어) speaking user. Leave tag names in English.", - ) - end - end - - describe("#available_prompts") do - before do - SiteSetting.ai_helper_illustrate_post_model = "disabled" - DiscourseAi::AiHelper::Assistant.clear_prompt_cache! - end - - it "returns all available prompts" do - prompts = subject.available_prompts(user) - - expect(prompts.map { |p| p[:name] }).to contain_exactly( - "translate", - "generate_titles", - "proofread", - "markdown_table", - "explain", - "replace_dates", - ) - end - - it "returns all prompts to be shown in the composer" do - prompts = subject.available_prompts(user) - filtered_prompts = prompts.select { |prompt| prompt[:location].include?("composer") } - - expect(filtered_prompts.map { |p| p[:name] }).to contain_exactly( - "translate", - "generate_titles", - "proofread", - "markdown_table", - "replace_dates", - ) - end - - it "returns all prompts to be shown in the post menu" do - prompts = subject.available_prompts(user) - filtered_prompts = prompts.select { |prompt| prompt[:location].include?("post") } - - expect(filtered_prompts.map { |p| p[:name] }).to contain_exactly( - "translate", - "explain", - "proofread", - ) - end - - it "does not raise an error when effective_locale does not exactly match keys in LocaleSiteSetting" do - SiteSetting.default_locale = "zh_CN" - expect { subject.available_prompts(user) }.not_to raise_error - end - - context "when illustrate post model is enabled" do - before do - SiteSetting.ai_helper_illustrate_post_model = "stable_diffusion_xl" - DiscourseAi::AiHelper::Assistant.clear_prompt_cache! - end - - it "returns the illustrate_post prompt in the list of all prompts" do - prompts = subject.available_prompts(user) - - expect(prompts.map { |p| p[:name] }).to contain_exactly( - "translate", - "generate_titles", - "proofread", - "markdown_table", - "explain", - "illustrate_post", - "replace_dates", - ) - end - end - end - - describe("#attach_user_context") do - before { SiteSetting.allow_user_locale = true } - - let(:context) { DiscourseAi::Personas::BotContext.new(user: user) } - - it "is able to perform %LANGUAGE% replacements" do - subject.attach_user_context(context, user) - - expect(context.user_language).to eq("English (US)") - end - - it "handles users with empty string locales" do - subject.attach_user_context(context, empty_locale_user) - - expect(context.user_language).to eq("English (US)") - end - - context "with temporal context" do - it "replaces temporal context with timezone information" do - timezone = "America/New_York" - user.user_option.update!(timezone: timezone) - freeze_time "2024-01-01 12:00:00" - - subject.attach_user_context(context, user) - - expect(context.temporal_context).to include(%("timezone":"America/New_York")) - end - - it "uses UTC as default timezone when user timezone is not set" do - user.user_option.update!(timezone: nil) - - freeze_time "2024-01-01 12:00:00" do - subject.attach_user_context(context, user) - - parsed_context = JSON.parse(context.temporal_context) - expect(parsed_context.dig("user", "timezone")).to eq("UTC") - end - end - - it "does not replace temporal context when user is nil" do - subject.attach_user_context(context, nil) - - expect(context.temporal_context).to be_nil - end - end - end - - describe "#generate_and_send_prompt" do - context "when using a prompt that returns text" do - let(:mode) { described_class::TRANSLATE } - - let(:text_to_translate) { <<~STRING } - Para que su horror sea perfecto, César, acosado al pie de la estatua por lo impacientes puñales de sus amigos, - descubre entre las caras y los aceros la de Marco Bruto, su protegido, acaso su hijo, - y ya no se defiende y exclama: ¡Tú también, hijo mío! Shakespeare y Quevedo recogen el patético grito. - STRING - - it "Sends the prompt to the LLM and returns the response" do - response = - DiscourseAi::Completions::Llm.with_prepared_responses([english_text]) do - subject.generate_and_send_prompt(mode, text_to_translate, user) - end - - expect(response[:suggestions]).to contain_exactly(english_text) - end - - context "when the persona is not using structured outputs" do - it "still works" do - regular_persona = Fabricate(:ai_persona, response_format: nil) - SiteSetting.ai_helper_translator_persona = regular_persona.id - - response = - DiscourseAi::Completions::Llm.with_prepared_responses([english_text]) do - subject.generate_and_send_prompt(mode, text_to_translate, user) - end - - expect(response[:suggestions]).to contain_exactly(english_text) - end - end - end - - context "when using a prompt that returns a list" do - let(:mode) { described_class::GENERATE_TITLES } - - let(:titles) do - { - output: [ - "The solitary horse", - "The horse etched in gold", - "A horse's infinite journey", - "A horse lost in time", - "A horse's last ride", - ], - } - end - - it "returns an array with each title" do - expected = [ - "The solitary horse", - "The horse etched in gold", - "A horse's infinite journey", - "A horse lost in time", - "A horse's last ride", - ] - - response = - DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do - subject.generate_and_send_prompt(mode, english_text, user) - end - - expect(response[:suggestions]).to contain_exactly(*expected) - end - end - end -end diff --git a/spec/lib/modules/ai_helper/chat_thread_titler_spec.rb b/spec/lib/modules/ai_helper/chat_thread_titler_spec.rb deleted file mode 100644 index b3ae54b0..00000000 --- a/spec/lib/modules/ai_helper/chat_thread_titler_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiHelper::ChatThreadTitler do - subject(:titler) { described_class.new(thread) } - - fab!(:thread) { Fabricate(:chat_thread) } - fab!(:chat_message) { Fabricate(:chat_message, thread: thread) } - fab!(:user) - - before do - enable_current_plugin - assign_fake_provider_to(:ai_helper_model) - end - - describe "#suggested_title" do - it "bails early if thread has no content" do - empty_thread = Chat::Thread.new - - result = described_class.new(empty_thread).suggested_title - - expect(result).to be_nil - end - end - - describe "#cleanup" do - it "picks the first when there are multiple" do - titles = "The solitary horse\nThe horse etched in gold" - expected_title = "The solitary horse" - - result = titler.cleanup(titles) - - expect(result).to eq(expected_title) - end - - it "cleans up double quotes enclosing the whole title" do - titles = '"The solitary horse"' - expected_title = "The solitary horse" - - result = titler.cleanup(titles) - - expect(result).to eq(expected_title) - end - - it "cleans up single quotes enclosing the whole title" do - titles = "'The solitary horse'" - expected_title = "The solitary horse" - - result = titler.cleanup(titles) - - expect(result).to eq(expected_title) - end - - it "leaves quotes in the middle of title" do - titles = "The 'solitary' horse" - expected_title = "The 'solitary' horse" - - result = titler.cleanup(titles) - - expect(result).to eq(expected_title) - end - - it "parses the XML" do - titles = "Here is your title <title>The solitary horse my friend" - expected_title = "The solitary horse" - - result = titler.cleanup(titles) - - expect(result).to eq(expected_title) - end - - it "truncates long titles" do - titles = "O cavalo trota pelo campo" + " Pocotó" * 100 - - result = titler.cleanup(titles) - - expect(result.size).to be <= 100 - end - end - - describe "#thread_content" do - it "returns the chat message and user" do - expect(titler.thread_content(thread)).to include(chat_message.message) - expect(titler.thread_content(thread)).to include(chat_message.user.username) - end - end -end diff --git a/spec/lib/modules/ai_helper/date_formatter_spec.rb b/spec/lib/modules/ai_helper/date_formatter_spec.rb deleted file mode 100644 index 9fd9dba5..00000000 --- a/spec/lib/modules/ai_helper/date_formatter_spec.rb +++ /dev/null @@ -1,262 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiHelper::DateFormatter do - fab!(:user) - - # Reference time is Tuesday Jan 16th, 2024 at 2:30 PM Sydney time - let(:sydney_reference) { DateTime.parse("2024-01-16 14:30:00 +11:00") } - - before { enable_current_plugin } - - describe ".process_date_placeholders" do - describe "with Sydney timezone" do - before do - user.user_option.update!(timezone: "Australia/Sydney") - freeze_time(sydney_reference) - end - - describe "date_time_offset_minutes" do - it "handles minute offsets" do - expect( - described_class.process_date_placeholders( - "Meeting in {{date_time_offset_minutes:30}}", - user, - ), - ).to eq("Meeting in [date=2024-01-16 time=15:00:00 timezone=\"Australia/Sydney\"]") - - expect( - described_class.process_date_placeholders( - "Meeting in {{date_time_offset_minutes:90}}", - user, - ), - ).to eq("Meeting in [date=2024-01-16 time=16:00:00 timezone=\"Australia/Sydney\"]") - end - - it "handles minute ranges" do - expect( - described_class.process_date_placeholders( - "Meeting {{date_time_offset_minutes:60:180}}", - user, - ), - ).to eq( - "Meeting [date-range from=2024-01-16T15:30:00 to=2024-01-16T17:30:00 timezone=\"Australia/Sydney\"]", - ) - end - end - - describe "date_offset_days" do - it "handles day offsets" do - expect( - described_class.process_date_placeholders("Due {{date_offset_days:1}}", user), - ).to eq("Due [date=2024-01-17 timezone=\"Australia/Sydney\"]") - - expect( - described_class.process_date_placeholders("Due {{date_offset_days:7}}", user), - ).to eq("Due [date=2024-01-23 timezone=\"Australia/Sydney\"]") - end - - it "handles day ranges" do - expect( - described_class.process_date_placeholders("Event {{date_offset_days:1:3}}", user), - ).to eq("Event [date-range from=2024-01-17 to=2024-01-19 timezone=\"Australia/Sydney\"]") - end - end - - describe "datetime" do - it "handles absolute times today" do - expect( - described_class.process_date_placeholders("Meeting at {{datetime:2pm}}", user), - ).to eq("Meeting at [date=2024-01-16 time=14:00:00 timezone=\"Australia/Sydney\"]") - - expect( - described_class.process_date_placeholders("Meeting at {{datetime:10pm}}", user), - ).to eq("Meeting at [date=2024-01-16 time=22:00:00 timezone=\"Australia/Sydney\"]") - end - - it "handles absolute times with day offset" do - expect( - described_class.process_date_placeholders("Meeting {{datetime:2pm+1}}", user), - ).to eq("Meeting [date=2024-01-17 time=14:00:00 timezone=\"Australia/Sydney\"]") - end - - it "handles time ranges" do - expect( - described_class.process_date_placeholders("Meeting {{datetime:2pm:4pm}}", user), - ).to eq( - "Meeting [date-range from=2024-01-16T14:00:00 to=2024-01-16T16:00:00 timezone=\"Australia/Sydney\"]", - ) - end - - it "handles time ranges with day offsets" do - expect( - described_class.process_date_placeholders("Meeting {{datetime:2pm+1:4pm+1}}", user), - ).to eq( - "Meeting [date-range from=2024-01-17T14:00:00 to=2024-01-17T16:00:00 timezone=\"Australia/Sydney\"]", - ) - end - - it "handles 12-hour time edge cases" do - expect(described_class.process_date_placeholders("At {{datetime:12am}}", user)).to eq( - "At [date=2024-01-16 time=00:00:00 timezone=\"Australia/Sydney\"]", - ) - - expect(described_class.process_date_placeholders("At {{datetime:12pm}}", user)).to eq( - "At [date=2024-01-16 time=12:00:00 timezone=\"Australia/Sydney\"]", - ) - end - end - - describe "next_week" do - it "handles next week days" do - expect( - described_class.process_date_placeholders("Meeting {{next_week:tuesday}}", user), - ).to eq("Meeting [date=2024-01-23 timezone=\"Australia/Sydney\"]") - end - - it "handles next week with specific times" do - expect( - described_class.process_date_placeholders("Meeting {{next_week:tuesday-2pm}}", user), - ).to eq("Meeting [date=2024-01-23 time=14:00:00 timezone=\"Australia/Sydney\"]") - end - - it "handles next week time ranges" do - expect( - described_class.process_date_placeholders( - "Meeting {{next_week:tuesday-1pm:tuesday-3pm}}", - user, - ), - ).to eq( - "Meeting [date-range from=2024-01-23T13:00:00 to=2024-01-23T15:00:00 timezone=\"Australia/Sydney\"]", - ) - end - end - end - - describe "with Los Angeles timezone" do - before do - user.user_option.update!(timezone: "America/Los_Angeles") - # Still freeze at Sydney time, but formatter should work in LA time - freeze_time(sydney_reference) - end - - it "handles current time conversions" do - # When it's 2:30 PM Tuesday in Sydney - # It's 7:30 PM Monday in LA - expect( - described_class.process_date_placeholders( - "Meeting {{date_time_offset_minutes:30}}", - user, - ), - ).to eq("Meeting [date=2024-01-15 time=20:00:00 timezone=\"America/Los_Angeles\"]") - end - - it "handles absolute times" do - expect(described_class.process_date_placeholders("Meeting {{datetime:2pm}}", user)).to eq( - "Meeting [date=2024-01-15 time=14:00:00 timezone=\"America/Los_Angeles\"]", - ) - end - - describe "next_week" do - it "handles next week days in LA time" do - # From Monday night in LA (Tuesday in Sydney) - expect( - described_class.process_date_placeholders("Meeting {{next_week:tuesday}}", user), - ).to eq("Meeting [date=2024-01-23 timezone=\"America/Los_Angeles\"]") - end - - it "handles next week with specific times in LA" do - expect( - described_class.process_date_placeholders("Meeting {{next_week:tuesday-2pm}}", user), - ).to eq("Meeting [date=2024-01-23 time=14:00:00 timezone=\"America/Los_Angeles\"]") - end - - it "handles next week time ranges in LA" do - expect( - described_class.process_date_placeholders( - "Meeting {{next_week:tuesday-1pm:tuesday-3pm}}", - user, - ), - ).to eq( - "Meeting [date-range from=2024-01-23T13:00:00 to=2024-01-23T15:00:00 timezone=\"America/Los_Angeles\"]", - ) - end - end - - it "handles day transitions across timezones" do - expect(described_class.process_date_placeholders("Due {{date_offset_days:1}}", user)).to eq( - "Due [date=2024-01-16 timezone=\"America/Los_Angeles\"]", - ) - end - end - - describe "with UTC timezone" do - before do - user.user_option.update!(timezone: nil) # defaults to UTC - freeze_time(sydney_reference) - end - - it "defaults to UTC for users without timezone" do - # When it's 2:30 PM in Sydney - # It's 3:30 AM in UTC - expect( - described_class.process_date_placeholders( - "Meeting {{date_time_offset_minutes:30}}", - user, - ), - ).to eq("Meeting [date=2024-01-16 time=04:00:00 timezone=\"UTC\"]") - end - - describe "next_week" do - it "handles next week calculations in UTC" do - expect( - described_class.process_date_placeholders("Meeting {{next_week:tuesday-2pm}}", user), - ).to eq("Meeting [date=2024-01-23 time=14:00:00 timezone=\"UTC\"]") - end - end - end - - describe "error handling" do - before do - user.user_option.update!(timezone: "Australia/Sydney") - freeze_time(sydney_reference) - end - - it "raises on invalid day name" do - expect { - described_class.process_date_placeholders("Meeting {{next_week:notaday}}", user) - }.to raise_error(ArgumentError) - end - - it "raises on invalid time format" do - expect { - described_class.process_date_placeholders("Meeting {{datetime:25pm}}", user) - }.to raise_error(ArgumentError) - end - end - - describe "mixed formats" do - before do - user.user_option.update!(timezone: "Australia/Sydney") - freeze_time(sydney_reference) - end - - it "handles multiple different formats in the same text" do - input = [ - "Meeting {{datetime:2pm+1}},", - "duration {{date_time_offset_minutes:60:180}},", - "repeats until {{date_offset_days:7}}", - "with sessions {{next_week:tuesday-1pm:tuesday-3pm}}", - ].join(" ") - - expected = [ - "Meeting [date=2024-01-17 time=14:00:00 timezone=\"Australia/Sydney\"],", - "duration [date-range from=2024-01-16T15:30:00 to=2024-01-16T17:30:00 timezone=\"Australia/Sydney\"],", - "repeats until [date=2024-01-23 timezone=\"Australia/Sydney\"]", - "with sessions [date-range from=2024-01-23T13:00:00 to=2024-01-23T15:00:00 timezone=\"Australia/Sydney\"]", - ].join(" ") - - expect(described_class.process_date_placeholders(input, user)).to eq(expected) - end - end - end -end diff --git a/spec/lib/modules/ai_helper/entry_point_spec.rb b/spec/lib/modules/ai_helper/entry_point_spec.rb deleted file mode 100644 index 9ffb5a5d..00000000 --- a/spec/lib/modules/ai_helper/entry_point_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::AiHelper::EntryPoint do - fab!(:english_user) { Fabricate(:user) } - fab!(:french_user) { Fabricate(:user, locale: "fr") } - - before { enable_current_plugin } - - it "will correctly localize available prompts" do - assign_fake_provider_to(:ai_helper_model) - SiteSetting.default_locale = "en" - SiteSetting.allow_user_locale = true - SiteSetting.ai_helper_enabled = true - SiteSetting.composer_ai_helper_allowed_groups = "10" # tl0 - DiscourseAi::AiHelper::Assistant.clear_prompt_cache! - - Group.refresh_automatic_groups! - - serializer = CurrentUserSerializer.new(english_user, scope: Guardian.new(english_user)) - parsed = JSON.parse(serializer.to_json) - - translate_prompt = - parsed["current_user"]["ai_helper_prompts"].find { |prompt| prompt["name"] == "translate" } - - expect(translate_prompt["translated_name"]).to eq( - I18n.t("discourse_ai.ai_helper.prompts.translate", language: "English (US)"), - ) - - I18n.with_locale("fr") do - serializer = CurrentUserSerializer.new(french_user, scope: Guardian.new(french_user)) - parsed = JSON.parse(serializer.to_json) - - translate_prompt = - parsed["current_user"]["ai_helper_prompts"].find { |prompt| prompt["name"] == "translate" } - - expect(translate_prompt["translated_name"]).to eq( - I18n.t("discourse_ai.ai_helper.prompts.translate", locale: "fr", language: "Français"), - ) - end - end - - it "will include auto_image_caption field in the user_option if image caption is enabled" do - assign_fake_provider_to(:ai_helper_model) - assign_fake_provider_to(:ai_helper_image_caption_model) - SiteSetting.ai_helper_enabled = true - SiteSetting.ai_helper_enabled_features = "image_caption" - SiteSetting.ai_auto_image_caption_allowed_groups = "10" # tl0 - serializer = CurrentUserSerializer.new(english_user, scope: Guardian.new(english_user)) - - expect(serializer.user_option.auto_image_caption).to eq(false) - end -end diff --git a/spec/lib/modules/ai_helper/painter_spec.rb b/spec/lib/modules/ai_helper/painter_spec.rb deleted file mode 100644 index 289dbc93..00000000 --- a/spec/lib/modules/ai_helper/painter_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiHelper::Painter do - subject(:painter) { described_class.new } - - fab!(:user) - - before do - enable_current_plugin - assign_fake_provider_to(:ai_helper_model) - SiteSetting.ai_stability_api_url = "https://api.stability.dev" - SiteSetting.ai_stability_api_key = "abc" - SiteSetting.ai_openai_api_key = "abc" - end - - describe "#commission_thumbnails" do - context "when illustrate post model is stable_diffusion_xl" do - before { SiteSetting.ai_helper_illustrate_post_model = "stable_diffusion_xl" } - - let(:artifacts) do - %w[ - iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg== - iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC - iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC - iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC - ] - end - - let(:raw_content) do - "Poetry is a form of artistic expression that uses language aesthetically and rhythmically to evoke emotions and ideas." - end - - let(:expected_image_prompt) { <<~TEXT.strip } - Visualize a vibrant scene of an inkwell bursting, spreading colors across a blank canvas, - embodying words in tangible forms, symbolizing the rhythm and emotion evoked by poetry, - under the soft glow of a full moon. - TEXT - - it "returns 4 samples" do - StableDiffusionStubs.new.stub_response(expected_image_prompt, artifacts) - - thumbnails = - DiscourseAi::Completions::Llm.with_prepared_responses([expected_image_prompt]) do - thumbnails = subject.commission_thumbnails(raw_content, user) - end - - thumbnail_urls = Upload.last(4).map(&:short_url) - - expect( - thumbnails.map { |upload_serializer| upload_serializer.short_url }, - ).to contain_exactly(*thumbnail_urls) - end - end - - context "when illustrate post model is dall_e_3" do - before { SiteSetting.ai_helper_illustrate_post_model = "dall_e_3" } - - let(:artifacts) do - %w[ - iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg== - ] - end - - let(:raw_content) do - "Poetry is a form of artistic expression that uses language aesthetically and rhythmically to evoke emotions and ideas." - end - - it "returns an image sample" do - _post = Fabricate(:post) - - data = [{ b64_json: artifacts.first, revised_prompt: "colors on a canvas" }] - WebMock - .stub_request(:post, "https://api.openai.com/v1/images/generations") - .with do |request| - _json = JSON.parse(request.body, symbolize_names: true) - true - end - .to_return(status: 200, body: { data: data }.to_json) - - thumbnails = subject.commission_thumbnails(raw_content, user) - thumbnail_urls = Upload.last(1).map(&:short_url) - - expect( - thumbnails.map { |upload_serializer| upload_serializer.short_url }, - ).to contain_exactly(*thumbnail_urls) - end - end - end -end diff --git a/spec/lib/modules/ai_helper/semantic_categorizer_spec.rb b/spec/lib/modules/ai_helper/semantic_categorizer_spec.rb deleted file mode 100644 index effec0e9..00000000 --- a/spec/lib/modules/ai_helper/semantic_categorizer_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiHelper::SemanticCategorizer do - fab!(:vector_def) { Fabricate(:embedding_definition) } - fab!(:user) - fab!(:muted_category) { Fabricate(:category) } - fab!(:category_mute) do - CategoryUser.create!( - user: user, - category: muted_category, - notification_level: CategoryUser.notification_levels[:muted], - ) - end - fab!(:muted_topic) { Fabricate(:topic, category: muted_category) } - fab!(:category) - fab!(:topic) { Fabricate(:topic, category: category) } - - let(:vector) { DiscourseAi::Embeddings::Vector.instance } - let(:categorizer) { DiscourseAi::AiHelper::SemanticCategorizer.new(user, { text: "hello" }) } - let(:expected_embedding) { [0.0038493] * vector.vdef.dimensions } - - before do - enable_current_plugin - - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_enabled = true - - WebMock.stub_request(:post, vector_def.url).to_return( - status: 200, - body: JSON.dump([expected_embedding]), - ) - - vector.generate_representation_from(topic) - vector.generate_representation_from(muted_topic) - end - - it "respects user muted categories when making suggestions" do - category_ids = categorizer.categories.map { |c| c[:id] } - expect(category_ids).not_to include(muted_category.id) - expect(category_ids).to include(category.id) - end -end diff --git a/spec/lib/modules/ai_moderation/spam_scanner_spec.rb b/spec/lib/modules/ai_moderation/spam_scanner_spec.rb deleted file mode 100644 index b6a6ba66..00000000 --- a/spec/lib/modules/ai_moderation/spam_scanner_spec.rb +++ /dev/null @@ -1,378 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::AiModeration::SpamScanner do - fab!(:moderator) - fab!(:user) { Fabricate(:user, trust_level: TrustLevel[0]) } - fab!(:topic) - fab!(:post) { Fabricate(:post, user: user, topic: topic) } - fab!(:llm_model) - fab!(:spam_setting) do - AiModerationSetting.create!( - setting_type: :spam, - llm_model: llm_model, - data: { - custom_instructions: "test instructions", - }, - ) - end - - before do - enable_current_plugin - SiteSetting.ai_spam_detection_enabled = true - end - - describe ".enabled?" do - it "returns true when both settings are enabled" do - expect(described_class.enabled?).to eq(true) - end - - it "returns false when discourse_ai is disabled" do - SiteSetting.discourse_ai_enabled = false - expect(described_class.enabled?).to eq(false) - end - - it "returns false when spam detection is disabled" do - SiteSetting.ai_spam_detection_enabled = false - expect(described_class.enabled?).to eq(false) - end - end - - describe ".should_scan_post?" do - it "returns true for new users' posts" do - expect(described_class.should_scan_post?(post)).to eq(true) - end - - it "returns false for trusted users" do - post.user.trust_level = TrustLevel[2] - expect(described_class.should_scan_post?(post)).to eq(false) - end - - it "returns false for bots" do - post.user.id = -100 - expect(described_class.should_scan_post?(post)).to eq(false) - end - - it "returns false for staff" do - post.user.moderator = true - expect(described_class.should_scan_post?(post)).to eq(false) - end - - it "returns false for users with many public posts" do - Fabricate(:post, user: user, topic: topic) - Fabricate(:post, user: user, topic: topic) - expect(described_class.should_scan_post?(post)).to eq(true) - - pm = Fabricate(:private_message_topic, user: user) - Fabricate(:post, user: user, topic: pm) - - expect(described_class.should_scan_post?(post)).to eq(true) - - topic = Fabricate(:topic, user: user) - Fabricate(:post, user: user, topic: topic) - - expect(described_class.should_scan_post?(post)).to eq(false) - end - - it "returns false for private messages" do - pm_topic = Fabricate(:private_message_topic) - pm_post = Fabricate(:post, topic: pm_topic, user: user) - expect(described_class.should_scan_post?(pm_post)).to eq(false) - end - - it "returns false for nil posts" do - expect(described_class.should_scan_post?(nil)).to eq(false) - end - end - - describe ".perform_scan" do - it "does nothing if post should not be scanned" do - post.user.trust_level = TrustLevel[2] - - expect { described_class.perform_scan(post) }.not_to change { AiSpamLog.count } - end - - it "scans when post should be scanned" do - expect do - DiscourseAi::Completions::Llm.with_prepared_responses(["spam"]) do - described_class.perform_scan!(post) - end - end.to change { AiSpamLog.count }.by(1) - end - end - - describe ".perform_scan!" do - it "creates spam log entry when scanning post" do - expect do - DiscourseAi::Completions::Llm.with_prepared_responses(["spam"]) do - described_class.perform_scan!(post) - end - end.to change { AiSpamLog.count }.by(1) - end - - it "does nothing when disabled" do - SiteSetting.ai_spam_detection_enabled = false - expect { described_class.perform_scan!(post) }.not_to change { AiSpamLog.count } - end - end - - describe ".scanned_max_times?" do - it "returns true when post has been scanned 3 times" do - 3.times do - AiSpamLog.create!(post: post, llm_model: llm_model, ai_api_audit_log_id: 1, is_spam: false) - end - - expect(described_class.scanned_max_times?(post)).to eq(true) - end - - it "returns false for posts scanned less than 3 times" do - expect(described_class.scanned_max_times?(post)).to eq(false) - end - end - - describe ".significant_change?" do - it "returns true for first edits" do - expect(described_class.significant_change?(nil, "new content")).to eq(true) - end - - it "returns true for significant changes" do - old_version = "This is a test post" - new_version = "This is a completely different post with new content" - expect(described_class.significant_change?(old_version, new_version)).to eq(true) - end - - it "returns false for minor changes" do - old_version = "This is a test post" - new_version = "This is a test Post" # Only capitalization change - expect(described_class.significant_change?(old_version, new_version)).to eq(false) - end - end - - describe ".new_post" do - it "enqueues spam scan job for eligible posts" do - expect { - described_class.new_post(post) - described_class.after_cooked_post(post) - }.to change(Jobs::AiSpamScan.jobs, :size).by(1) - end - - it "doesn't enqueue jobs when disabled" do - SiteSetting.ai_spam_detection_enabled = false - expect { described_class.new_post(post) }.not_to change(Jobs::AiSpamScan.jobs, :size) - end - end - - describe ".edited_post" do - it "enqueues spam scan job for eligible edited posts" do - PostRevision.create!( - post: post, - modifications: { - raw: ["old content", "completely new content"], - }, - ) - - expect { - described_class.edited_post(post) - described_class.after_cooked_post(post) - }.to change(Jobs::AiSpamScan.jobs, :size).by(1) - end - - it "schedules delayed job when edited too soon after last scan" do - AiSpamLog.create!( - post: post, - llm_model: llm_model, - ai_api_audit_log_id: 1, - is_spam: false, - created_at: 5.minutes.ago, - ) - - expect { - described_class.edited_post(post) - described_class.after_cooked_post(post) - }.to change(Jobs::AiSpamScan.jobs, :size).by(1) - end - end - - describe ".hide_post" do - fab!(:spam_post) { Fabricate(:post, user: user) } - fab!(:second_spam_post) { Fabricate(:post, topic: spam_post.topic, user: user) } - - it "hides spam post and topic for first post" do - described_class.hide_post(spam_post) - - expect(spam_post.reload.hidden).to eq(true) - expect(second_spam_post.reload.hidden).to eq(false) - expect(spam_post.reload.hidden_reason_id).to eq( - Post.hidden_reasons[:new_user_spam_threshold_reached], - ) - end - - it "doesn't hide the topic for non-first posts" do - described_class.hide_post(second_spam_post) - - expect(spam_post.reload.hidden).to eq(false) - expect(second_spam_post.reload.hidden).to eq(true) - expect(spam_post.topic.reload.visible).to eq(true) - end - end - - it "unsilences flagging user if erronuously silenced" do - described_class.flagging_user.update!(silenced_till: 1.day.from_now) - expect(described_class.flagging_user.silenced?).to eq(false) - end - - it "ensures flagging user is tl4" do - described_class.flagging_user.update!(trust_level: 0) - expect(described_class.flagging_user.trust_level).to eq(4) - end - - it "unsuspends user if it was erronuously suspended" do - described_class.flagging_user.update!(suspended_till: 1.day.from_now, suspended_at: 1.day.ago) - expect(described_class.flagging_user.suspended?).to eq(false) - end - - it "makes sure account is active" do - described_class.flagging_user.update!(active: false) - expect(described_class.flagging_user.active).to eq(true) - end - - describe "integration test" do - fab!(:llm_model) - let(:api_audit_log) { Fabricate(:api_audit_log) } - fab!(:post_with_uploaded_image) - - before { Jobs.run_immediately! } - - it "can correctly run tests" do - prompts = nil - result = - DiscourseAi::Completions::Llm.with_prepared_responses( - [true, "the reason is just because"], - ) do |_, _, _prompts| - prompts = _prompts - described_class.test_post(post, custom_instructions: "123") - end - - expect(prompts.length).to eq(2) - expect(result[:is_spam]).to eq(true) - expect(result[:log]).to include("123") - expect(result[:log]).to include("just because") - - result = - DiscourseAi::Completions::Llm.with_prepared_responses( - [false, "the reason is just because"], - ) do |_, _, _prompts| - prompts = _prompts - described_class.test_post(post, custom_instructions: "123") - end - - expect(result[:is_spam]).to eq(false) - end - - it "correctly handles spam scanning" do - expect(described_class.flagging_user.id).not_to eq(Discourse.system_user.id) - - post = post_with_uploaded_image - # this is surprising, core fabricator is not linking - # we need it linked so we scan uploads - post.link_post_uploads - - expect(described_class.should_scan_post?(post)).to eq(true) - expect(post.upload_ids).to be_present - - described_class.new_post(post) - - prompt = nil - DiscourseAi::Completions::Llm.with_prepared_responses([true]) do |_, _, _prompts| - # force a rebake so we actually scan - post.rebake! - prompt = _prompts.first - end - - # its an array so lets just stringify it to make testing easier - content = prompt.messages[1][:content][0] - expect(content).to include(post.topic.title) - expect(content).to include(post.raw) - - upload_ids = prompt.messages[1][:content].map { |m| m[:upload_id] if m.is_a?(Hash) }.compact - expect(upload_ids).to be_present - expect(upload_ids).to eq(post.upload_ids) - - log = AiSpamLog.find_by(post: post) - - expect(log.payload).to eq(content) - expect(log.is_spam).to eq(true) - expect(post.user.reload.silenced_till).to be_present - expect(post.topic.reload.visible).to eq(false) - - history = UserHistory.where(action: UserHistory.actions[:silence_user]).order(:id).last - - url = "#{Discourse.base_url}/admin/plugins/discourse-ai/ai-spam" - - expect(history.target_user_id).to eq(post.user_id) - expect(history.details).to include( - I18n.t("discourse_ai.spam_detection.silence_reason", url: url), - ) - - expect(log.reviewable).to be_present - expect(log.reviewable.created_by_id).to eq(described_class.flagging_user.id) - - log.reviewable.perform(moderator, :disagree_and_restore) - - expect(post.reload.hidden?).to eq(false) - expect(post.topic.reload.visible).to eq(true) - expect(post.user.reload.silenced?).to eq(false) - end - - it "does not silence the user or hide the post when a flag cannot be created" do - post = post_with_uploaded_image - Fabricate( - :post_action, - post: post, - user: described_class.flagging_user, - post_action_type_id: PostActionType.types[:spam], - ) - - described_class.new_post(post) - - DiscourseAi::Completions::Llm.with_prepared_responses([true]) do |_, _, _prompts| - # force a rebake so we actually scan - post.rebake! - end - - log = AiSpamLog.find_by(post: post) - - expect(log.reviewable).to be_nil - expect(log.error).to match(/unable to flag post as spam/) - expect(post.user.reload).not_to be_silenced - expect(post.topic.reload).to be_visible - end - end - - it "includes location information and email in context" do - user.update!(ip_address: "1.2.3.4", registration_ip_address: "5.6.7.8") - - ip_info_registration = { location: "New York", organization: "ISP1" } - ip_info_last = { location: "London", organization: "ISP2" } - - DiscourseIpInfo - .stubs(:get) - .with("5.6.7.8", resolve_hostname: true) - .returns(ip_info_registration) - DiscourseIpInfo.stubs(:get).with("1.2.3.4", resolve_hostname: true).returns(ip_info_last) - - prompts = nil - DiscourseAi::Completions::Llm.with_prepared_responses( - [true, "just because"], - ) do |_, _, _prompts| - prompts = _prompts - described_class.test_post(post) - end - - context = prompts.first.messages[1][:content] - expect(context).to include("Registration Location: New York (ISP1)") - expect(context).to include("Last Location: London (ISP2)") - expect(context).to include(user.email) - end -end diff --git a/spec/lib/modules/automation/llm_triage_spec.rb b/spec/lib/modules/automation/llm_triage_spec.rb deleted file mode 100644 index 71d85ada..00000000 --- a/spec/lib/modules/automation/llm_triage_spec.rb +++ /dev/null @@ -1,265 +0,0 @@ -# frozen_string_literal: true -describe DiscourseAi::Automation::LlmTriage do - fab!(:post) - fab!(:reply) { Fabricate(:post, topic: post.topic, user: Fabricate(:user)) } - fab!(:llm_model) - - def triage(**args) - DiscourseAi::Automation::LlmTriage.handle(**args) - end - - before { enable_current_plugin } - - it "does nothing if it does not pass triage" do - DiscourseAi::Completions::Llm.with_prepared_responses(["good"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - hide_topic: true, - system_prompt: "test %%POST%%", - search_for_text: "bad", - automation: nil, - ) - end - - expect(post.topic.reload.visible).to eq(true) - end - - it "can hide topics on triage" do - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - hide_topic: true, - system_prompt: "test %%POST%%", - search_for_text: "bad", - automation: nil, - ) - end - - expect(post.topic.reload.visible).to eq(false) - end - - it "can categorize topics on triage" do - category = Fabricate(:category) - - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - category_id: category.id, - system_prompt: "test %%POST%%", - search_for_text: "bad", - automation: nil, - ) - end - - expect(post.topic.reload.category_id).to eq(category.id) - end - - it "can reply to topics on triage" do - user = Fabricate(:user) - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - canned_reply: "test canned reply 123", - canned_reply_user: user.username, - automation: nil, - ) - end - - reply = post.topic.posts.order(:post_number).last - - expect(reply.raw).to eq("test canned reply 123") - expect(reply.user.id).to eq(user.id) - end - - it "can add posts to the review queue" do - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - automation: nil, - ) - end - - reviewable = ReviewablePost.last - - expect(reviewable.target).to eq(post) - expect(reviewable.reviewable_scores.first.reason).to include("bad") - end - - it "can handle spam flags" do - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - flag_type: :spam, - automation: nil, - ) - end - - expect(post.reload).to be_hidden - expect(post.topic.reload.visible).to eq(false) - end - - it "can handle spam+silence flags" do - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - flag_type: :spam_silence, - automation: nil, - ) - end - - expect(post.reload).to be_hidden - expect(post.topic.reload.visible).to eq(false) - expect(post.user.silenced?).to eq(true) - end - - it "can handle flag + hide" do - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - flag_type: :review_hide, - automation: nil, - ) - end - - reviewable = ReviewablePost.last - - expect(reviewable.target).to eq(post) - expect(reviewable.reviewable_scores.first.reason).to include("bad") - expect(post.reload).to be_hidden - end - - it "does not silence the user if the flag fails" do - Fabricate( - :post_action, - post: post, - user: Discourse.system_user, - post_action_type_id: PostActionType.types[:spam], - ) - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - flag_type: :spam_silence, - automation: nil, - ) - end - - expect(post.user.reload).not_to be_silenced - end - - it "can handle garbled output from LLM" do - DiscourseAi::Completions::Llm.with_prepared_responses(["Bad.\n\nYo"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - automation: nil, - ) - end - - reviewable = ReviewablePost.last - - expect(reviewable&.target).to eq(post) - end - - it "treats search_for_text as case-insensitive" do - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "BAD", - flag_post: true, - automation: nil, - ) - end - - reviewable = ReviewablePost.last - - expect(reviewable.target).to eq(post) - end - - it "includes post uploads when triaging" do - post_upload = Fabricate(:image_upload, posts: [post]) - - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - automation: nil, - ) - - triage_prompt = DiscourseAi::Completions::Llm.prompts.last - - expect(triage_prompt.messages.last[:content].last).to eq({ upload_id: post_upload.id }) - end - end - - it "includes stop_sequences in the completion call" do - sequences = %w[GOOD BAD] - - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do |spy| - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - automation: nil, - stop_sequences: sequences, - ) - - expect(spy.model_params[:stop_sequences]).to contain_exactly(*sequences) - end - end - - it "append rule tags instead of replacing them" do - tag_1 = Fabricate(:tag) - tag_2 = Fabricate(:tag) - post.topic.update!(tags: [tag_1]) - - DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do - triage( - post: post, - model: "custom:#{llm_model.id}", - system_prompt: "test %%POST%%", - search_for_text: "bad", - flag_post: true, - tags: [tag_2.name], - automation: nil, - ) - end - - expect(post.topic.reload.tags).to contain_exactly(tag_1, tag_2) - end -end diff --git a/spec/lib/modules/automation/report_context_generator_spec.rb b/spec/lib/modules/automation/report_context_generator_spec.rb deleted file mode 100644 index b9378d7d..00000000 --- a/spec/lib/modules/automation/report_context_generator_spec.rb +++ /dev/null @@ -1,157 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi - module Automation - describe ReportContextGenerator do - describe ".generate" do - fab!(:private_message_post) - fab!(:post_in_other_category) { Fabricate(:post) } - - fab!(:category) - fab!(:topic) { Fabricate(:topic, category: category) } - fab!(:post_in_category) { Fabricate(:post, topic: topic) } - fab!(:reply_in_category) { Fabricate(:post, topic: topic, reply_to_post_number: 1) } - - fab!(:group) - fab!(:private_category) { Fabricate(:private_category, group: group) } - fab!(:secure_topic) do - Fabricate(:topic, title: "category in secure category", category: private_category) - end - fab!(:user_in_group) { Fabricate(:user, groups: [group]) } - fab!(:post_in_private_category) do - Fabricate(:post, user: user_in_group, topic: secure_topic) - end - - fab!(:tag) - fab!(:tag2) { Fabricate(:tag) } - fab!(:topic_with_tag) { Fabricate(:topic, tags: [tag, tag2]) } - fab!(:post_with_tag) { Fabricate(:post, topic: topic_with_tag) } - - fab!(:long_post) do - Fabricate( - :post, - raw: (1..100).map { |i| "testing#{i}" }.join(" "), - topic: Fabricate(:topic, category: category), - ) - end - - fab!(:topic_with_likes) { Fabricate(:topic, like_count: 10) } - - fab!(:post_with_likes) { Fabricate(:post, topic: topic_with_likes, like_count: 10) } - - fab!(:post_with_likes2) { Fabricate(:post, topic: topic_with_likes, like_count: 5) } - - fab!(:post_with_likes3) { Fabricate(:post, topic: topic_with_likes, like_count: 3) } - - before { enable_current_plugin } - - if defined?(::DiscourseSolved) - it "will correctly denote solved topics" do - Fabricate(:solved_topic, topic: topic_with_likes, answer_post: post_with_likes2) - - context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day) - - expect(context).to include("solved: true") - expect(context).to include("solution: true") - end - end - - it "will exclude non visible topics" do - post_with_likes3.topic.update(visible: false) - - context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day) - - expect(context).not_to include("topic_id: #{topic_with_likes.id}") - end - - it "always includes info from last posts on topic" do - context = - ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day, max_posts: 1) - - expect(context).to include("...") - expect(context).to include("post_number: 3") - end - - it "includes a summary" do - context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day) - - expect(context).to include("New posts: 8") - expect(context).to include("New topics: 5") - end - - it "orders so most liked are first" do - context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day) - - regex = "topic_id: #{topic_with_likes.id}.*topic_id: #{long_post.topic.id}" - expect(context).to match(Regexp.new(regex, Regexp::MULTILINE)) - end - - it "allows you to prioritize groups" do - context = - ReportContextGenerator.generate( - start_date: 1.day.ago, - duration: 2.day, - prioritized_group_ids: [group.id], - allow_secure_categories: true, - max_posts: 1, - ) - - expect(context).to include(post_in_private_category.topic.title) - expect(context).not_to include(post_in_other_category.topic.title) - expect(context).to include(group.name) - end - - it "can generate context (excluding PMs)" do - context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day) - - expect(context).to include(post_in_other_category.topic.title) - expect(context).to include(topic.title) - expect(context).not_to include(private_message_post.topic.title) - expect(context).not_to include(secure_topic.title) - end - - it "can filter on tag" do - context = - ReportContextGenerator.generate( - start_date: 1.day.ago, - duration: 2.day, - tags: [tag.name], - ) - - expect(context).not_to include(post_in_other_category.topic.title) - expect(context).not_to include(topic.title) - expect(context).not_to include(private_message_post.topic.title) - expect(context).not_to include(secure_topic.title) - expect(context).to include(post_with_tag.topic.title) - end - - it "can optionally include secure categories" do - context = - ReportContextGenerator.generate( - start_date: 1.day.ago, - duration: 2.day, - allow_secure_categories: true, - ) - expect(context).to include(post_in_other_category.topic.title) - expect(context).to include(topic.title) - expect(context).not_to include(private_message_post.topic.title) - expect(context).to include(secure_topic.title) - end - - it "can filter to a categories" do - context = - ReportContextGenerator.generate( - start_date: 1.day.ago, - duration: 2.day, - category_ids: [category.id], - ) - - expect(context).not_to include(post_in_other_category.topic.title) - expect(context).to include(topic.title) - expect(context).not_to include(private_message_post.topic.title) - expect(context).not_to include(secure_topic.title) - end - end - end - end -end diff --git a/spec/lib/modules/automation/report_runner_spec.rb b/spec/lib/modules/automation/report_runner_spec.rb deleted file mode 100644 index 7fe4ddde..00000000 --- a/spec/lib/modules/automation/report_runner_spec.rb +++ /dev/null @@ -1,271 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module DiscourseAi - module Automation - describe ReportRunner do - fab!(:user) - fab!(:receiver) { Fabricate(:user) } - fab!(:post) { Fabricate(:post, user: user) } - fab!(:group) - fab!(:secure_category) { Fabricate(:private_category, group: group) } - fab!(:secure_topic) { Fabricate(:topic, category: secure_category) } - fab!(:secure_post) { Fabricate(:post, raw: "Top secret date !!!!", topic: secure_topic) } - - fab!(:category) - fab!(:topic_in_category) { Fabricate(:topic, category: category) } - fab!(:post_in_category) do - Fabricate(:post, raw: "I am in a category", topic: topic_in_category) - end - - fab!(:tag) - fab!(:hidden_tag) { Fabricate(:tag, name: "hidden-tag") } - fab!(:tag_group) do - tag_group = TagGroup.new(name: "test tag group") - tag_group.tag_group_permissions.build(group_id: Group::AUTO_GROUPS[:trust_level_1]) - - tag_group.save! - TagGroupMembership.create!(tag_group_id: tag_group.id, tag_id: hidden_tag.id) - tag_group - end - fab!(:topic_with_tag) { Fabricate(:topic, tags: [tag, hidden_tag]) } - fab!(:post_with_tag) { Fabricate(:post, raw: "I am in a tag", topic: topic_with_tag) } - - fab!(:llm_model) - - before { enable_current_plugin } - - describe "#run!" do - it "is able to generate email reports" do - freeze_time - - DiscourseAi::Completions::Llm.with_prepared_responses(["magical report"]) do - ReportRunner.run!( - sender_username: user.username, - receivers: ["fake@discourse.com"], - title: "test report %DATE%", - model: "custom:#{llm_model.id}", - persona_id: - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - category_ids: nil, - tags: nil, - allow_secure_categories: false, - sample_size: 100, - instructions: "make a magic report", - days: 7, - offset: 0, - priority_group_id: nil, - tokens_per_post: 150, - debug_mode: nil, - ) - end - - expect(ActionMailer::Base.deliveries.length).to eq(1) - expect(ActionMailer::Base.deliveries.first.subject).to eq( - "test report #{7.days.ago.strftime("%Y-%m-%d")} - #{Time.zone.now.strftime("%Y-%m-%d")}", - ) - end - - it "can exclude categories (including sub categories)" do - subcategory = Fabricate(:category, parent_category_id: category.id) - topic_in_subcategory = Fabricate(:topic, category: subcategory) - post_in_subcategory = - Fabricate(:post, raw: "I am in a subcategory abcd", topic: topic_in_subcategory) - - other_category = Fabricate(:category) - topic2 = Fabricate(:topic, category: other_category) - post2 = Fabricate(:post, raw: "I am in another category 123", topic: topic2) - - freeze_time - - DiscourseAi::Completions::Llm.with_prepared_responses(["magical report"]) do - ReportRunner.run!( - sender_username: user.username, - receivers: [receiver.username], - title: "test report", - model: "custom:#{llm_model.id}", - persona_id: - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - category_ids: nil, - tags: nil, - allow_secure_categories: false, - debug_mode: true, - sample_size: 100, - instructions: "make a magic report", - days: 7, - offset: 0, - priority_group_id: nil, - tokens_per_post: 150, - exclude_category_ids: [category.id], - ) - end - - report = Topic.where(title: "test report").first - debugging = report.ordered_posts.last.raw - - expect(debugging).not_to include(post_in_category.raw) - expect(debugging).not_to include(post_in_subcategory.raw) - expect(debugging).to include(post2.raw) - expect(debugging).to include(post_with_tag.raw) - expect(debugging).to include(tag.name) - expect(debugging).not_to include(hidden_tag.name) - end - - it "can suppress notifications by remapping content" do - user = Fabricate(:user) - - markdown = <<~MD - @#{user.username} is a person - [test1](/test) is an internal link - [test2](/test?1=2) is an internal link - [test3](https://example.com) is an external link - [test4](#{Discourse.base_url}) is an internal link - test5 is an internal link - [test6](/test?test=test#anchor) is an internal link with fragment - [test7](//[[test) is a link with an invalid URL - MD - - DiscourseAi::Completions::Llm.with_prepared_responses([markdown]) do - ReportRunner.run!( - sender_username: user.username, - receivers: [receiver.username], - title: "test report", - model: "custom:#{llm_model.id}", - persona_id: - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - category_ids: nil, - tags: nil, - allow_secure_categories: false, - debug_mode: false, - sample_size: 100, - instructions: "make a magic report", - days: 7, - offset: 0, - priority_group_id: nil, - tokens_per_post: 150, - suppress_notifications: true, - ) - end - - report = Topic.where(title: "test report").first - - # note, magic surprise & is correct HTML 5 representation - expected = <<~HTML -

#{user.username} is a person
- test1 is an internal link
- test2 is an internal link
- test3 is an external link
- test4 is an internal link
- test5 is an internal link
- test6 is an internal link with fragment
- test7 is a link with an invalid URL

- HTML - - post = report.ordered_posts.first - - expect(post.mentions.to_a).to eq([]) - expect(post.raw.strip).to eq(expected.strip) - end - - it "can exclude tags" do - freeze_time - - DiscourseAi::Completions::Llm.with_prepared_responses(["magical report"]) do - ReportRunner.run!( - sender_username: user.username, - receivers: [receiver.username], - title: "test report", - model: "custom:#{llm_model.id}", - persona_id: - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - category_ids: nil, - tags: nil, - allow_secure_categories: false, - debug_mode: true, - sample_size: 100, - instructions: "make a magic report", - days: 7, - offset: 0, - priority_group_id: nil, - tokens_per_post: 150, - exclude_tags: [tag.name], - ) - end - - report = Topic.where(title: "test report").first - debugging = report.ordered_posts.last.raw - - expect(debugging).to include(post_in_category.raw) - expect(debugging).not_to include(post_with_tag.raw) - end - - it "can send reports to groups only" do - group_for_reports = Fabricate(:group) - group_member = Fabricate(:user) - group_for_reports.add(group_member) - - DiscourseAi::Completions::Llm.with_prepared_responses(["group report"]) do - ReportRunner.run!( - sender_username: user.username, - receivers: [group_for_reports.name], - title: "group report", - model: "custom:#{llm_model.id}", - persona_id: - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - category_ids: nil, - tags: nil, - allow_secure_categories: false, - debug_mode: false, - sample_size: 100, - instructions: "make a group report", - days: 7, - offset: 0, - priority_group_id: nil, - tokens_per_post: 150, - ) - end - - report_topic = - Topic.where(title: "group report", archetype: Archetype.private_message).first - expect(report_topic).to be_present - expect(report_topic.allowed_groups.map(&:id)).to eq([group_for_reports.id]) - expect(report_topic.allowed_users.map(&:id)).to eq([user.id]) - expect(report_topic.ordered_posts.first.raw).to eq("group report") - end - - it "generates correctly respects the params" do - DiscourseAi::Completions::Llm.with_prepared_responses(["magical report"]) do - ReportRunner.run!( - sender_username: user.username, - receivers: [receiver.username], - title: "test report", - model: "custom:#{llm_model.id}", - persona_id: - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::ReportRunner], - category_ids: nil, - tags: nil, - allow_secure_categories: false, - debug_mode: true, - sample_size: 100, - instructions: "make a magic report", - days: 7, - offset: 0, - priority_group_id: nil, - tokens_per_post: 150, - ) - end - - report = Topic.where(title: "test report").first - expect(report.ordered_posts.first.raw).to eq("magical report") - debugging = report.ordered_posts.last.raw - - expect(debugging).to include(post.raw) - expect(debugging).to include(post_in_category.raw) - expect(debugging).to include(post_with_tag.raw) - expect(debugging).not_to include(secure_post.raw) - end - end - end - end -end diff --git a/spec/lib/modules/embeddings/entry_point_spec.rb b/spec/lib/modules/embeddings/entry_point_spec.rb deleted file mode 100644 index 4657a70c..00000000 --- a/spec/lib/modules/embeddings/entry_point_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Embeddings::EntryPoint do - fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } - - fab!(:embedding_definition) - - before do - enable_current_plugin - SiteSetting.ai_embeddings_selected_model = embedding_definition.id - end - - describe "registering event callbacks" do - context "when creating a topic" do - let(:creator) do - PostCreator.new( - user, - raw: "this is the new content for my topic", - title: "this is my new topic title", - ) - end - - it "queues a job on create if embeddings is enabled" do - SiteSetting.ai_embeddings_enabled = true - - expect { creator.create }.to change(Jobs::GenerateEmbeddings.jobs, :size).by(1) # topic_created - end - - it "queues two jobs on create if embeddings is enabled" do - SiteSetting.ai_embeddings_enabled = true - - expect { creator.create }.to change(Jobs::GenerateEmbeddings.jobs, :size).by(1) # topic_created AND post_created - end - - it "does nothing if embeddings analysis is disabled" do - SiteSetting.ai_embeddings_enabled = false - - expect { creator.create }.not_to change(Jobs::GenerateEmbeddings.jobs, :size) - end - end - end -end diff --git a/spec/lib/modules/embeddings/jobs/generate_embeddings_spec.rb b/spec/lib/modules/embeddings/jobs/generate_embeddings_spec.rb deleted file mode 100644 index 6d7cec65..00000000 --- a/spec/lib/modules/embeddings/jobs/generate_embeddings_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::GenerateEmbeddings do - subject(:job) { described_class.new } - - fab!(:vector_def) { Fabricate(:embedding_definition) } - - before { enable_current_plugin } - - describe "#execute" do - before do - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_enabled = true - end - - fab!(:topic) - fab!(:post) { Fabricate(:post, post_number: 1, topic: topic) } - - let(:topics_schema) { DiscourseAi::Embeddings::Schema.for(Topic) } - let(:posts_schema) { DiscourseAi::Embeddings::Schema.for(Post) } - - it "works for topics" do - expected_embedding = [0.0038493] * vector_def.dimensions - - text = vector_def.prepare_target_text(topic) - - EmbeddingsGenerationStubs.hugging_face_service(text, expected_embedding) - - job.execute(target_id: topic.id, target_type: "Topic") - - expect(topics_schema.find_by_embedding(expected_embedding).topic_id).to eq(topic.id) - end - - it "works for posts" do - expected_embedding = [0.0038493] * vector_def.dimensions - - text = vector_def.prepare_target_text(post) - EmbeddingsGenerationStubs.hugging_face_service(text, expected_embedding) - - job.execute(target_id: post.id, target_type: "Post") - - expect(posts_schema.find_by_embedding(expected_embedding).post_id).to eq(post.id) - end - end -end diff --git a/spec/lib/modules/embeddings/schema_spec.rb b/spec/lib/modules/embeddings/schema_spec.rb deleted file mode 100644 index 2a2956ee..00000000 --- a/spec/lib/modules/embeddings/schema_spec.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Embeddings::Schema do - subject(:posts_schema) { described_class.for(Post) } - - fab!(:vector_def) { Fabricate(:cloudflare_embedding_def) } - let(:embeddings) { [0.0038490295] * vector_def.dimensions } - fab!(:post) { Fabricate(:post, post_number: 1) } - let(:digest) { OpenSSL::Digest.hexdigest("SHA1", "test") } - - before do - enable_current_plugin - SiteSetting.ai_embeddings_selected_model = vector_def.id - posts_schema.store(post, embeddings, digest) - end - - describe "#find_by_target" do - it "gets you the post_id of the record that matches the post" do - embeddings_record = posts_schema.find_by_target(post) - - expect(embeddings_record.digest).to eq(digest) - expect(JSON.parse(embeddings_record.embeddings)).to eq(embeddings) - end - end - - describe "#find_by_embedding" do - it "gets you the record that matches the embedding" do - embeddings_record = posts_schema.find_by_embedding(embeddings) - - expect(embeddings_record.digest).to eq(digest) - expect(embeddings_record.post_id).to eq(post.id) - end - end - - describe "similarity searches" do - fab!(:post_2) { Fabricate(:post) } - let(:similar_embeddings) { [0.0038490294] * vector_def.dimensions } - - describe "#symmetric_similarity_search" do - before { posts_schema.store(post_2, similar_embeddings, digest) } - - it "returns target_id with similar embeddings" do - similar_records = posts_schema.symmetric_similarity_search(post) - - expect(similar_records.map(&:post_id)).to contain_exactly(post.id, post_2.id) - end - - it "let's you apply additional scopes to filter results further" do - similar_records = - posts_schema.symmetric_similarity_search(post) do |builder| - builder.where("post_id = ?", post_2.id) - end - - expect(similar_records.map(&:post_id)).to contain_exactly(post_2.id) - end - - it "let's you join on additional tables and combine with additional scopes" do - similar_records = - posts_schema.symmetric_similarity_search(post) do |builder| - builder.join("posts p on p.id = post_id") - builder.join("topics t on t.id = p.topic_id") - builder.where("t.id = ?", post_2.topic_id) - end - - expect(similar_records.map(&:post_id)).to contain_exactly(post_2.id) - end - end - - describe "#asymmetric_similarity_search" do - it "returns target_id with similar embeddings" do - similar_records = - posts_schema.asymmetric_similarity_search(similar_embeddings, limit: 1, offset: 0) - - expect(similar_records.map(&:post_id)).to contain_exactly(post.id) - end - - it "let's you apply additional scopes to filter results further" do - similar_records = - posts_schema.asymmetric_similarity_search( - similar_embeddings, - limit: 1, - offset: 0, - ) { |builder| builder.where("post_id <> ?", post.id) } - - expect(similar_records.map(&:post_id)).to be_empty - end - - it "let's you join on additional tables and combine with additional scopes" do - similar_records = - posts_schema.asymmetric_similarity_search( - similar_embeddings, - limit: 1, - offset: 0, - ) do |builder| - builder.join("posts p on p.id = post_id") - builder.join("topics t on t.id = p.topic_id") - builder.where("t.id <> ?", post.topic_id) - end - - expect(similar_records.map(&:post_id)).to be_empty - end - end - end -end diff --git a/spec/lib/modules/embeddings/semantic_related_spec.rb b/spec/lib/modules/embeddings/semantic_related_spec.rb deleted file mode 100644 index 620d323d..00000000 --- a/spec/lib/modules/embeddings/semantic_related_spec.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Embeddings::SemanticRelated do - subject(:semantic_related) { described_class.new } - - fab!(:target) { Fabricate(:topic) } - fab!(:normal_topic_1) { Fabricate(:topic) } - fab!(:normal_topic_2) { Fabricate(:topic) } - fab!(:normal_topic_3) { Fabricate(:topic) } - fab!(:unlisted_topic) { Fabricate(:topic, visible: false) } - fab!(:private_topic) { Fabricate(:private_message_topic) } - fab!(:secured_category) { Fabricate(:category, read_restricted: true) } - fab!(:secured_category_topic) { Fabricate(:topic, category: secured_category) } - fab!(:closed_topic) { Fabricate(:topic, closed: true) } - - fab!(:vector_def) { Fabricate(:embedding_definition) } - - before do - enable_current_plugin - SiteSetting.ai_embeddings_semantic_related_topics_enabled = true - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_enabled = true - end - - describe "#related_topic_ids_for" do - it "returns empty array if AI embeddings are disabled" do - SiteSetting.ai_embeddings_enabled = false - SiteSetting.ai_embeddings_selected_model = 1234 - - expect(semantic_related.related_topic_ids_for(normal_topic_1)).to eq([]) - end - - it "returns empty array if AI embeddings model is invalid" do - SiteSetting.ai_embeddings_enabled = true - SiteSetting.ai_embeddings_selected_model = 1234 - - expect(semantic_related.related_topic_ids_for(normal_topic_1)).to eq([]) - end - - context "when embeddings do not exist" do - let(:topic) do - post = Fabricate(:post) - topic = post.topic - described_class.clear_cache_for(target) - topic - end - - it "properly generates embeddings if missing" do - Jobs.run_immediately! - - embedding = Array.new(1024) { 1 } - - WebMock.stub_request(:post, vector_def.url).to_return( - status: 200, - body: JSON.dump([embedding]), - ) - - # miss first - ids = semantic_related.related_topic_ids_for(topic) - - # clear cache so we lookup - described_class.clear_cache_for(topic) - - # hit cause we queued generation - ids = semantic_related.related_topic_ids_for(topic) - - # at this point though the only embedding is ourselves - expect(ids).to eq([topic.id]) - end - - it "queues job only once per 15 minutes" do - results = nil - - expect_enqueued_with( - job: :generate_embeddings, - args: { - target_id: topic.id, - target_type: "Topic", - }, - ) { results = semantic_related.related_topic_ids_for(topic) } - - expect(results).to eq([]) - - expect_not_enqueued_with( - job: :generate_embeddings, - args: { - target_id: topic.id, - target_type: "Topic", - }, - ) { results = semantic_related.related_topic_ids_for(topic) } - - expect(results).to eq([]) - end - end - end -end diff --git a/spec/lib/modules/embeddings/semantic_search_spec.rb b/spec/lib/modules/embeddings/semantic_search_spec.rb deleted file mode 100644 index 832f72d9..00000000 --- a/spec/lib/modules/embeddings/semantic_search_spec.rb +++ /dev/null @@ -1,137 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Embeddings::SemanticSearch do - fab!(:post) - fab!(:user) - - let(:query) { "test_query" } - let(:subject) { described_class.new(Guardian.new(user)) } - - fab!(:vector_def) { Fabricate(:embedding_definition) } - - before do - enable_current_plugin - SiteSetting.ai_embeddings_selected_model = vector_def.id - assign_fake_provider_to(:ai_embeddings_semantic_search_hyde_model) - end - - describe "#search_for_topics" do - let(:hypothetical_post) { "This is an hypothetical post generated from the keyword test_query" } - let(:hyde_embedding) { [0.049382] * vector_def.dimensions } - - before { EmbeddingsGenerationStubs.hugging_face_service(hypothetical_post, hyde_embedding) } - - after { described_class.clear_cache_for(query) } - - def insert_candidate(candidate) - DiscourseAi::Embeddings::Schema.for(Topic).store(candidate, hyde_embedding, "digest") - end - - def trigger_search(query) - DiscourseAi::Completions::Llm.with_prepared_responses([hypothetical_post]) do - subject.search_for_topics(query) - end - end - - it "returns the first post of a topic included in the asymmetric search results" do - insert_candidate(post.topic) - - posts = trigger_search(query) - - expect(posts).to contain_exactly(post) - end - - describe "applies different scopes to the candidates" do - context "when the topic is not visible" do - it "returns an empty list" do - post.topic.update!(visible: false) - insert_candidate(post.topic) - - posts = trigger_search(query) - - expect(posts).to be_empty - end - end - - context "when the post is not public" do - it "returns an empty list" do - pm_post = Fabricate(:private_message_post) - insert_candidate(pm_post.topic) - - posts = trigger_search(query) - - expect(posts).to be_empty - end - end - - context "when the post type is not visible" do - it "returns an empty list" do - post.update!(post_type: Post.types[:whisper]) - insert_candidate(post.topic) - - posts = trigger_search(query) - - expect(posts).to be_empty - end - end - - context "when the post is not the first post in the topic" do - it "returns an empty list" do - reply = Fabricate(:reply) - reply.topic.first_post.trash! - insert_candidate(reply.topic) - - posts = trigger_search(query) - - expect(posts).to be_empty - end - end - - context "when the post is not a candidate" do - it "doesn't include it in the results" do - post_2 = Fabricate(:post) - insert_candidate(post.topic) - - posts = trigger_search(query) - - expect(posts).not_to include(post_2) - end - end - - context "when the post belongs to a secured category" do - fab!(:group) - fab!(:private_category) { Fabricate(:private_category, group: group) } - - before do - post.topic.update!(category: private_category) - insert_candidate(post.topic) - end - - it "returns an empty list" do - posts = trigger_search(query) - - expect(posts).to be_empty - end - - it "returns the results if the user has access to the category" do - group.add(user) - - posts = trigger_search(query) - - expect(posts).to contain_exactly(post) - end - - context "while searching as anon" do - it "returns an empty list" do - posts = - DiscourseAi::Completions::Llm.with_prepared_responses([hypothetical_post]) do - described_class.new(Guardian.new(nil)).search_for_topics(query) - end - - expect(posts).to be_empty - end - end - end - end - end -end diff --git a/spec/lib/modules/embeddings/semantic_topic_query_spec.rb b/spec/lib/modules/embeddings/semantic_topic_query_spec.rb deleted file mode 100644 index d5ebe9d7..00000000 --- a/spec/lib/modules/embeddings/semantic_topic_query_spec.rb +++ /dev/null @@ -1,150 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Embeddings::EntryPoint do - fab!(:user) - - before { enable_current_plugin } - - describe "SemanticTopicQuery extension" do - describe "#list_semantic_related_topics" do - subject(:topic_query) { DiscourseAi::Embeddings::SemanticTopicQuery.new(user) } - - fab!(:target) { Fabricate(:topic) } - - fab!(:vector_def) { Fabricate(:cloudflare_embedding_def) } - - before do - SiteSetting.ai_embeddings_enabled = true - SiteSetting.ai_embeddings_selected_model = vector_def.id - end - - # The Distance gap to target increases for each element of topics. - def seed_embeddings(topics) - schema = DiscourseAi::Embeddings::Schema.for(Topic) - base_value = 1 - - schema.store(target, [base_value] * 1024, "disgest") - - topics.each do |t| - base_value -= 0.01 - schema.store(t, [base_value] * 1024, "digest") - end - end - - after { DiscourseAi::Embeddings::SemanticRelated.clear_cache_for(target) } - - context "when the semantic search returns an unlisted topic" do - fab!(:unlisted_topic) { Fabricate(:topic, visible: false) } - - before { seed_embeddings([unlisted_topic]) } - - it "filters it out" do - expect(topic_query.list_semantic_related_topics(target).topics).to be_empty - end - end - - context "when the semantic search returns a private topic" do - fab!(:private_topic) { Fabricate(:private_message_topic) } - - before { seed_embeddings([private_topic]) } - - it "filters it out" do - expect(topic_query.list_semantic_related_topics(target).topics).to be_empty - end - end - - context "when the semantic search returns a topic from a restricted category" do - fab!(:group) - fab!(:category) { Fabricate(:private_category, group: group) } - fab!(:secured_category_topic) { Fabricate(:topic, category: category) } - - before { seed_embeddings([secured_category_topic]) } - - it "filters it out" do - expect(topic_query.list_semantic_related_topics(target).topics).to be_empty - end - - it "doesn't filter it out if the user has access to the category" do - group.add(user) - - expect(topic_query.list_semantic_related_topics(target).topics).to contain_exactly( - secured_category_topic, - ) - end - end - - context "when the semantic search returns a closed topic and we explicitly exclude them" do - fab!(:closed_topic) { Fabricate(:topic, closed: true) } - - before do - SiteSetting.ai_embeddings_semantic_related_include_closed_topics = false - seed_embeddings([closed_topic]) - end - - it "filters it out" do - expect(topic_query.list_semantic_related_topics(target).topics).to be_empty - end - end - - context "when the semantic search returns a muted topic" do - it "filters it out" do - category = Fabricate(:category_with_definition) - topic = Fabricate(:topic, category: category) - CategoryUser.create!( - user_id: user.id, - category_id: category.id, - notification_level: CategoryUser.notification_levels[:muted], - ) - seed_embeddings([topic]) - expect(topic_query.list_semantic_related_topics(target).topics).not_to include(topic) - end - end - - context "when the semantic search returns public topics" do - fab!(:normal_topic_1) { Fabricate(:topic) } - fab!(:normal_topic_2) { Fabricate(:topic) } - fab!(:normal_topic_3) { Fabricate(:topic) } - fab!(:closed_topic) { Fabricate(:topic, closed: true) } - - before { seed_embeddings([closed_topic, normal_topic_1, normal_topic_2, normal_topic_3]) } - - it "filters it out" do - expect(topic_query.list_semantic_related_topics(target).topics).to eq( - [closed_topic, normal_topic_1, normal_topic_2, normal_topic_3], - ) - end - - it "returns the plugin limit for the number of results" do - SiteSetting.ai_embeddings_semantic_related_topics = 2 - - expect(topic_query.list_semantic_related_topics(target).topics).to contain_exactly( - closed_topic, - normal_topic_1, - ) - end - end - - context "with semantic_related_topics_query modifier registered" do - fab!(:included_topic) { Fabricate(:topic) } - fab!(:excluded_topic) { Fabricate(:topic) } - - before { seed_embeddings([included_topic, excluded_topic]) } - - let(:modifier_block) { Proc.new { |query| query.where.not(id: excluded_topic.id) } } - - it "Allows modifications to default results (excluding a topic in this case)" do - plugin_instance = Plugin::Instance.new - plugin_instance.register_modifier(:semantic_related_topics_query, &modifier_block) - - expect(topic_query.list_semantic_related_topics(target).topics).to eq([included_topic]) - ensure - DiscoursePluginRegistry.unregister_modifier( - plugin_instance, - :semantic_related_topics_query, - &modifier_block - ) - end - end - end - end -end diff --git a/spec/lib/modules/embeddings/strategies/truncation_spec.rb b/spec/lib/modules/embeddings/strategies/truncation_spec.rb deleted file mode 100644 index 0e303cce..00000000 --- a/spec/lib/modules/embeddings/strategies/truncation_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Embeddings::Strategies::Truncation do - subject(:truncation) { described_class.new } - - fab!(:open_ai_embedding_def) - let(:prefix) { "I come first:" } - - before { enable_current_plugin } - - describe "#prepare_target_text" do - before { SiteSetting.max_post_length = 100_000 } - - fab!(:topic) - fab!(:post) do - Fabricate(:post, topic: topic, raw: "Baby, bird, bird, bird\nBird is the word\n" * 500) - end - fab!(:post) do - Fabricate( - :post, - topic: topic, - raw: "Don't you know about the bird?\nEverybody knows that the bird is a word\n" * 400, - ) - end - fab!(:post) { Fabricate(:post, topic: topic, raw: "Surfin' bird\n" * 800) } - fab!(:open_ai_embedding_def) - - it "truncates a topic" do - prepared_text = truncation.prepare_target_text(topic, open_ai_embedding_def) - - expect(open_ai_embedding_def.tokenizer.size(prepared_text)).to be <= - open_ai_embedding_def.max_sequence_length - end - - it "includes embed prefix" do - open_ai_embedding_def.update!(embed_prompt: prefix) - - prepared_text = truncation.prepare_target_text(topic, open_ai_embedding_def) - - expect(prepared_text.starts_with?(prefix)).to eq(true) - end - end - - describe "#prepare_query_text" do - context "when search is asymetric" do - it "includes search prefix" do - open_ai_embedding_def.update!(search_prompt: prefix) - - prepared_query_text = - truncation.prepare_query_text("searching", open_ai_embedding_def, asymetric: true) - - expect(prepared_query_text.starts_with?(prefix)).to eq(true) - end - end - end -end diff --git a/spec/lib/modules/embeddings/vector_spec.rb b/spec/lib/modules/embeddings/vector_spec.rb deleted file mode 100644 index 89736636..00000000 --- a/spec/lib/modules/embeddings/vector_spec.rb +++ /dev/null @@ -1,184 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Embeddings::Vector do - before { enable_current_plugin } - - shared_examples "generates and store embeddings using a vector definition" do - subject(:vector) { described_class.new(vdef) } - - let(:expected_embedding_1) { [0.0038493] * vdef.dimensions } - let(:expected_embedding_2) { [0.0037684] * vdef.dimensions } - - before { SiteSetting.ai_embeddings_selected_model = vdef.id } - - let(:topics_schema) { DiscourseAi::Embeddings::Schema.for(Topic) } - let(:posts_schema) { DiscourseAi::Embeddings::Schema.for(Post) } - - fab!(:topic) - fab!(:post) { Fabricate(:post, post_number: 1, topic: topic) } - fab!(:post2) { Fabricate(:post, post_number: 2, topic: topic) } - - describe "#vector_from" do - it "creates a vector from a given string" do - text = "This is a piece of text" - stub_vector_mapping(text, expected_embedding_1) - - expect(vector.vector_from(text)).to eq(expected_embedding_1) - end - end - - describe "#generate_representation_from" do - it "creates a vector from a topic and stores it in the database" do - text = vdef.prepare_target_text(topic) - stub_vector_mapping(text, expected_embedding_1) - - vector.generate_representation_from(topic) - - expect(topics_schema.find_by_embedding(expected_embedding_1).topic_id).to eq(topic.id) - end - - it "creates a vector from a post and stores it in the database" do - text = vdef.prepare_target_text(post2) - stub_vector_mapping(text, expected_embedding_1) - - vector.generate_representation_from(post) - - expect(posts_schema.find_by_embedding(expected_embedding_1).post_id).to eq(post.id) - end - end - - describe "#gen_bulk_reprensentations" do - fab!(:topic_2) { Fabricate(:topic) } - fab!(:post_2_1) { Fabricate(:post, post_number: 1, topic: topic_2) } - fab!(:post_2_2) { Fabricate(:post, post_number: 2, topic: topic_2) } - - it "creates a vector for each object in the relation" do - text = vdef.prepare_target_text(topic) - - text2 = vdef.prepare_target_text(topic_2) - - stub_vector_mapping(text, expected_embedding_1) - stub_vector_mapping(text2, expected_embedding_2) - - vector.gen_bulk_reprensentations(Topic.where(id: [topic.id, topic_2.id])) - - expect(topics_schema.find_by_embedding(expected_embedding_1).topic_id).to eq(topic.id) - end - - it "does nothing if passed record has no content" do - expect { vector.gen_bulk_reprensentations([Topic.new]) }.not_to raise_error - end - - it "doesn't ask for a new embedding if digest is the same" do - text = vdef.prepare_target_text(topic) - stub_vector_mapping(text, expected_embedding_1) - - original_vector_gen = Time.zone.parse("2021-06-04 10:00") - - freeze_time(original_vector_gen) do - vector.gen_bulk_reprensentations(Topic.where(id: [topic.id])) - end - # check vector exists - expect(topics_schema.find_by_embedding(expected_embedding_1).topic_id).to eq(topic.id) - - vector.gen_bulk_reprensentations(Topic.where(id: [topic.id])) - - expect(topics_schema.find_by_target(topic).updated_at).to eq_time(original_vector_gen) - end - - context "when one of the concurrently generated embeddings fails" do - it "still processes the succesful ones" do - text = vdef.prepare_target_text(topic) - - text2 = vdef.prepare_target_text(topic_2) - - stub_vector_mapping(text, expected_embedding_1) - stub_vector_mapping(text2, expected_embedding_2, result_status: 429) - - vector.gen_bulk_reprensentations(Topic.where(id: [topic.id, topic_2.id])) - - expect(topics_schema.find_by_embedding(expected_embedding_1).topic_id).to eq(topic.id) - expect(topics_schema.find_by_target(topic_2)).to be_nil - end - end - end - end - - context "with open_ai as the provider" do - fab!(:vdef) { Fabricate(:open_ai_embedding_def) } - - def stub_vector_mapping(text, expected_embedding, result_status: 200) - EmbeddingsGenerationStubs.openai_service( - vdef.lookup_custom_param("model_name"), - text, - expected_embedding, - result_status: result_status, - ) - end - - it_behaves_like "generates and store embeddings using a vector definition" - - context "when matryoshka_dimensions is enabled" do - it "passes the dimensions param" do - shorter_dimensions = 10 - vdef.update!(dimensions: shorter_dimensions, matryoshka_dimensions: true) - text = "This is a piece of text" - short_expected_embedding = [0.0038493] * shorter_dimensions - - EmbeddingsGenerationStubs.openai_service( - vdef.lookup_custom_param("model_name"), - text, - short_expected_embedding, - extra_args: { - dimensions: shorter_dimensions, - }, - ) - - expect(described_class.new(vdef).vector_from(text)).to eq(short_expected_embedding) - end - end - end - - context "with hugging_face as the provider" do - fab!(:vdef) { Fabricate(:embedding_definition) } - - def stub_vector_mapping(text, expected_embedding, result_status: 200) - EmbeddingsGenerationStubs.hugging_face_service( - text, - expected_embedding, - result_status: result_status, - ) - end - - it_behaves_like "generates and store embeddings using a vector definition" - end - - context "with google as the provider" do - fab!(:vdef) { Fabricate(:gemini_embedding_def) } - - def stub_vector_mapping(text, expected_embedding, result_status: 200) - EmbeddingsGenerationStubs.gemini_service( - vdef.api_key, - text, - expected_embedding, - result_status: result_status, - ) - end - - it_behaves_like "generates and store embeddings using a vector definition" - end - - context "with cloudflare as the provider" do - fab!(:vdef) { Fabricate(:cloudflare_embedding_def) } - - def stub_vector_mapping(text, expected_embedding, result_status: 200) - EmbeddingsGenerationStubs.cloudflare_service( - text, - expected_embedding, - result_status: result_status, - ) - end - - it_behaves_like "generates and store embeddings using a vector definition" - end -end diff --git a/spec/lib/modules/sentiment/emotion_filter_order_spec.rb b/spec/lib/modules/sentiment/emotion_filter_order_spec.rb deleted file mode 100644 index 94b644df..00000000 --- a/spec/lib/modules/sentiment/emotion_filter_order_spec.rb +++ /dev/null @@ -1,205 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Sentiment::EmotionFilterOrder do - let(:plugin) { Plugin::Instance.new } - let(:model_used) { "SamLowe/roberta-base-go_emotions" } - let(:post_1) { Fabricate(:post) } - let(:post_2) { Fabricate(:post) } - let(:post_3) { Fabricate(:post) } - let(:guardian) { Guardian.new } - let(:classification_1) do - { - love: 0.9444406, - admiration: 0.013724019, - surprise: 0.010188869, - excitement: 0.007888741, - curiosity: 0.006301749, - joy: 0.004060776, - confusion: 0.0028238264, - approval: 0.0018160914, - realization: 0.001174849, - neutral: 0.0008561869, - amusement: 0.00075853954, - disapproval: 0.0006987994, - disappointment: 0.0006166883, - anger: 0.0006000542, - annoyance: 0.0005615011, - desire: 0.00046368592, - fear: 0.00045117878, - sadness: 0.00041727215, - gratitude: 0.00041727215, - optimism: 0.00037112957, - disgust: 0.00035552034, - nervousness: 0.00022954118, - embarrassment: 0.0002049572, - caring: 0.00017737568, - remorse: 0.00011407586, - grief: 0.0001006716, - pride: 0.00009681493, - relief: 0.00008919009, - } - end - let(:classification_2) do - { - love: 0.8444406, - admiration: 0.113724019, - surprise: 0.010188869, - excitement: 0.007888741, - curiosity: 0.006301749, - joy: 0.004060776, - confusion: 0.0028238264, - approval: 0.0018160914, - realization: 0.001174849, - neutral: 0.0008561869, - amusement: 0.00075853954, - disapproval: 0.0006987994, - disappointment: 0.0006166883, - anger: 0.0006000542, - annoyance: 0.0005615011, - desire: 0.00046368592, - fear: 0.00045117878, - sadness: 0.00041727215, - gratitude: 0.00041727215, - optimism: 0.00037112957, - disgust: 0.00035552034, - nervousness: 0.00022954118, - embarrassment: 0.0002049572, - caring: 0.00017737568, - remorse: 0.00011407586, - grief: 0.0001006716, - pride: 0.00009681493, - relief: 0.00008919009, - } - end - let(:classification_3) do - { - anger: 0.8503682, - annoyance: 0.08113059, - disgust: 0.020593312, - disapproval: 0.013718102, - neutral: 0.0074148285, - disappointment: 0.005785964, - sadness: 0.0028253668, - curiosity: 0.0028253668, - confusion: 0.0023885092, - surprise: 0.001524171, - embarrassment: 0.0012784768, - love: 0.001177788, - admiration: 0.0010892758, - realization: 0.001080799, - approval: 0.00102328, - fear: 0.00097261387, - amusement: 0.0007724123, - excitement: 0.00059921003, - gratitude: 0.00055852515, - joy: 0.00054986606, - optimism: 0.00050458545, - desire: 0.00046849172, - caring: 0.00037205798, - remorse: 0.00028415458, - grief: 0.00025973833, - nervousness: 0.00024305031, - pride: 0.00011661681, - relief: 0.00007470753, - } - end - let!(:classification_result_1) do - Fabricate( - :sentiment_classification, - target: post_1, - model_used: model_used, - classification: classification_1, - ) - end - let!(:classification_result_2) do - Fabricate( - :sentiment_classification, - target: post_2, - model_used: model_used, - classification: classification_2, - ) - end - let!(:classification_result_3) do - Fabricate( - :sentiment_classification, - target: post_3, - model_used: model_used, - classification: classification_3, - ) - end - - before do - enable_current_plugin - described_class.register!(plugin) - end - - it "registers emotion filters" do - emotions = %w[ - disappointment - sadness - annoyance - neutral - disapproval - realization - nervousness - approval - joy - anger - embarrassment - caring - remorse - disgust - grief - confusion - relief - desire - admiration - optimism - fear - love - excitement - curiosity - amusement - surprise - gratitude - pride - ] - - filters = DiscoursePluginRegistry.custom_filter_mappings.reduce(Hash.new, :merge) - - emotions.each { |emotion| expect(filters).to include("order:emotion_#{emotion}") } - end - - it "filters topics by emotion" do - emotion = "joy" - scope = Topic.all - order_direction = "desc" - - filter = - DiscoursePluginRegistry - .custom_filter_mappings - .find { _1.keys.include? "order:emotion_#{emotion}" } - .values - .first - result = filter.call(scope, order_direction, guardian) - - expect(result.to_sql).to include("classification_results") - expect(result.to_sql).to include( - "classification_results.model_used = 'SamLowe/roberta-base-go_emotions'", - ) - expect(result.to_sql).to include("ORDER BY topic_emotion.emotion_joy desc") - end - - it "sorts emotion in ascending order" do - expect( - TopicsFilter.new(guardian:).filter_from_query_string("order:emotion_love-asc").pluck(:id), - ).to contain_exactly(post_2.topic.id, post_1.topic.id) - end - it "sorts emotion in default descending order" do - expect( - TopicsFilter.new(guardian:).filter_from_query_string("order:emotion_love").pluck(:id), - ).to contain_exactly(post_1.topic.id, post_2.topic.id) - end -end diff --git a/spec/lib/modules/sentiment/entry_point_spec.rb b/spec/lib/modules/sentiment/entry_point_spec.rb deleted file mode 100644 index 0ca1d7a3..00000000 --- a/spec/lib/modules/sentiment/entry_point_spec.rb +++ /dev/null @@ -1,178 +0,0 @@ -# frozen_string_literal: true - -require_relative "../../../support/sentiment_inference_stubs" - -RSpec.describe DiscourseAi::Sentiment::EntryPoint do - fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } - - before { enable_current_plugin } - - describe "registering event callbacks" do - context "when editing a post" do - fab!(:post) { Fabricate(:post, user: user) } - let(:revisor) { PostRevisor.new(post) } - - it "queues a job on update if sentiment analysis is enabled" do - SiteSetting.ai_sentiment_enabled = true - - expect { revisor.revise!(user, raw: "This is my new test") }.to change( - Jobs::PostSentimentAnalysis.jobs, - :size, - ).by(1) - end - - it "does nothing if sentiment analysis is disabled" do - SiteSetting.ai_sentiment_enabled = false - - expect { revisor.revise!(user, raw: "This is my new test") }.not_to change( - Jobs::PostSentimentAnalysis.jobs, - :size, - ) - end - end - end - - describe "custom reports" do - before do - SiteSetting.ai_sentiment_model_configs = - "[{\"model_name\":\"SamLowe/roberta-base-go_emotions\",\"endpoint\":\"http://samlowe-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"j-hartmann/emotion-english-distilroberta-base\",\"endpoint\":\"http://jhartmann-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"cardiffnlp/twitter-roberta-base-sentiment-latest\",\"endpoint\":\"http://cardiffnlp-sentiment.com\",\"api_key\":\"123\"}]" - end - - fab!(:pm) { Fabricate(:private_message_post) } - - fab!(:post_1) { Fabricate(:post) } - fab!(:post_2) { Fabricate(:post) } - - describe "overall_sentiment report" do - let(:positive_classification) { { negative: 0.2, neutral: 0.3, positive: 0.7 } } - let(:negative_classification) { { negative: 0.65, neutral: 0.2, positive: 0.1 } } - - def sentiment_classification(post, classification) - Fabricate(:sentiment_classification, target: post, classification: classification) - end - - it "calculate averages using only public posts" do - sentiment_classification(post_1, positive_classification) - sentiment_classification(post_2, negative_classification) - sentiment_classification(pm, positive_classification) - - report = Report.find("overall_sentiment") - overall_sentiment = report.data[0][:data][0][:y].to_i - expect(overall_sentiment).to eq(0) - end - - it "exports the report without any errors" do - sentiment_classification(post_1, positive_classification) - sentiment_classification(post_2, negative_classification) - sentiment_classification(pm, positive_classification) - - exporter = Jobs::ExportCsvFile.new - exporter.entity = "report" - exporter.extra = HashWithIndifferentAccess.new(name: "overall_sentiment") - exported_csv = [] - exporter.report_export { |entry| exported_csv << entry } - expect(exported_csv[0]).to eq(["Day", "Overall sentiment (Positive - Negative)"]) - expect(exported_csv[1]).to eq([post_1.created_at.to_date.to_s, "0"]) - end - end - - describe "post_emotion report" do - let(:emotion_1) do - { - love: 0.9444406, - admiration: 0.013724019, - surprise: 0.010188869, - excitement: 0.007888741, - curiosity: 0.006301749, - joy: 0.004060776, - confusion: 0.0028238264, - approval: 0.0018160914, - realization: 0.001174849, - neutral: 0.0008561869, - amusement: 0.00075853954, - disapproval: 0.0006987994, - disappointment: 0.0006166883, - anger: 0.0006000542, - annoyance: 0.0005615011, - desire: 0.00046368592, - fear: 0.00045117878, - sadness: 0.00041727215, - gratitude: 0.00041727215, - optimism: 0.00037112957, - disgust: 0.00035552034, - nervousness: 0.00022954118, - embarrassment: 0.0002049572, - caring: 0.00017737568, - remorse: 0.00011407586, - grief: 0.0001006716, - pride: 0.00009681493, - relief: 0.00008919009, - } - end - let(:emotion_2) do - { - love: 0.8444406, - admiration: 0.113724019, - surprise: 0.010188869, - excitement: 0.007888741, - curiosity: 0.006301749, - joy: 0.004060776, - confusion: 0.0028238264, - approval: 0.0018160914, - realization: 0.001174849, - neutral: 0.0008561869, - amusement: 0.00075853954, - disapproval: 0.0006987994, - disappointment: 0.0006166883, - anger: 0.0006000542, - annoyance: 0.0005615011, - desire: 0.00046368592, - fear: 0.00045117878, - sadness: 0.00041727215, - gratitude: 0.00041727215, - optimism: 0.00037112957, - disgust: 0.00035552034, - nervousness: 0.00022954118, - embarrassment: 0.0002049572, - caring: 0.00017737568, - remorse: 0.00011407586, - grief: 0.0001006716, - pride: 0.00009681493, - relief: 0.00008919009, - } - end - let(:model_used) { "SamLowe/roberta-base-go_emotions" } - - def emotion_classification(post, classification) - Fabricate( - :sentiment_classification, - target: post, - model_used: model_used, - classification: classification, - ) - end - - def strip_emoji_and_downcase(str) - stripped_str = str.gsub(/[^\p{L}\p{N}]+/, "") # remove any non-alphanumeric characters - stripped_str.downcase - end - - it "calculate averages using only public posts" do - threshold = 0.10 - - emotion_classification(post_1, emotion_1) - emotion_classification(post_2, emotion_2) - emotion_classification(pm, emotion_2) - - report = Report.find("emotion_love") - - data_point = report.data - - data_point.each do |point| - expected = (emotion_1[:love] > threshold ? 1 : 0) + (emotion_2[:love] > threshold ? 1 : 0) - expect(point[:y]).to eq(expected) - end - end - end - end -end diff --git a/spec/lib/modules/sentiment/jobs/regular/post_sentiment_analysis_spec.rb b/spec/lib/modules/sentiment/jobs/regular/post_sentiment_analysis_spec.rb deleted file mode 100644 index 332417f6..00000000 --- a/spec/lib/modules/sentiment/jobs/regular/post_sentiment_analysis_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require_relative "../../../../../support/sentiment_inference_stubs" - -describe Jobs::PostSentimentAnalysis do - before { enable_current_plugin } - - describe "#execute" do - let(:post) { Fabricate(:post) } - - before do - SiteSetting.ai_sentiment_enabled = true - SiteSetting.ai_sentiment_model_configs = - "[{\"model_name\":\"SamLowe/roberta-base-go_emotions\",\"endpoint\":\"http://samlowe-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"j-hartmann/emotion-english-distilroberta-base\",\"endpoint\":\"http://jhartmann-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"cardiffnlp/twitter-roberta-base-sentiment-latest\",\"endpoint\":\"http://cardiffnlp-sentiment.com\",\"api_key\":\"123\"}]" - end - - describe "scenarios where we return early without doing anything" do - it "does nothing when ai_sentiment_enabled is disabled" do - SiteSetting.ai_sentiment_enabled = false - - subject.execute({ post_id: post.id }) - - expect(ClassificationResult.where(target: post).count).to be_zero - end - - it "does nothing if there's no arg called post_id" do - subject.execute({}) - - expect(ClassificationResult.where(target: post).count).to be_zero - end - - it "does nothing if no post match the given id" do - subject.execute({ post_id: nil }) - - expect(ClassificationResult.where(target: post).count).to be_zero - end - - it "does nothing if the post content is blank" do - post.update_columns(raw: "") - - subject.execute({ post_id: post.id }) - - expect(ClassificationResult.where(target: post).count).to be_zero - end - end - - it "successfully classifies the post" do - expected_analysis = DiscourseAi::Sentiment::PostClassification.new.classifiers.length - SentimentInferenceStubs.stub_classification(post) - - subject.execute({ post_id: post.id }) - - expect(ClassificationResult.where(target: post).count).to eq(expected_analysis) - end - end -end diff --git a/spec/lib/modules/sentiment/post_classification_spec.rb b/spec/lib/modules/sentiment/post_classification_spec.rb deleted file mode 100644 index b84abe90..00000000 --- a/spec/lib/modules/sentiment/post_classification_spec.rb +++ /dev/null @@ -1,213 +0,0 @@ -# frozen_string_literal: true - -require_relative "../../../support/sentiment_inference_stubs" - -RSpec.describe DiscourseAi::Sentiment::PostClassification do - before do - enable_current_plugin - SiteSetting.ai_sentiment_enabled = true - SiteSetting.ai_sentiment_model_configs = - "[{\"model_name\":\"SamLowe/roberta-base-go_emotions\",\"endpoint\":\"http://samlowe-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"j-hartmann/emotion-english-distilroberta-base\",\"endpoint\":\"http://jhartmann-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"cardiffnlp/twitter-roberta-base-sentiment-latest\",\"endpoint\":\"http://cardiffnlp-sentiment.com\",\"api_key\":\"123\"}]" - end - - def check_classification_for(post) - result = - ClassificationResult.find_by( - model_used: "cardiffnlp/twitter-roberta-base-sentiment-latest", - target: post, - ) - - expect(result.classification.keys).to contain_exactly("negative", "neutral", "positive") - end - - describe "#classify!" do - fab!(:post_1) { Fabricate(:post, post_number: 2) } - - it "does nothing if the post content is blank" do - post_1.update_columns(raw: "") - - subject.classify!(post_1) - - expect(ClassificationResult.where(target: post_1).count).to be_zero - end - - it "successfully classifies the post" do - expected_analysis = DiscourseAi::Sentiment::SentimentSiteSettingJsonSchema.values.length - SentimentInferenceStubs.stub_classification(post_1) - - subject.classify!(post_1) - - expect(ClassificationResult.where(target: post_1).count).to eq(expected_analysis) - end - - it "classification results must be { emotion => score }" do - SentimentInferenceStubs.stub_classification(post_1) - - subject.classify!(post_1) - check_classification_for(post_1) - end - - it "does nothing if there are no classification model" do - SiteSetting.ai_sentiment_model_configs = "" - - subject.classify!(post_1) - - expect(ClassificationResult.where(target: post_1).count).to be_zero - end - - it "don't reclassify everything when a model config changes" do - SentimentInferenceStubs.stub_classification(post_1) - - subject.classify!(post_1) - first_classified_at = 2.days.ago - ClassificationResult.update_all(created_at: first_classified_at) - - current_models = JSON.parse(SiteSetting.ai_sentiment_model_configs) - current_models << { model_name: "new", endpoint: "https://test.com", api_key: "123" } - SiteSetting.ai_sentiment_model_configs = current_models.to_json - - SentimentInferenceStubs.stub_classification(post_1) - subject.classify!(post_1.reload) - - new_classifications = ClassificationResult.where("created_at > ?", first_classified_at).count - expect(new_classifications).to eq(1) - end - end - - describe "#classify_bulk!" do - fab!(:post_1) { Fabricate(:post, post_number: 2) } - fab!(:post_2) { Fabricate(:post, post_number: 2) } - - it "classifies all given posts" do - expected_analysis = DiscourseAi::Sentiment::SentimentSiteSettingJsonSchema.values.length - SentimentInferenceStubs.stub_classification(post_1) - SentimentInferenceStubs.stub_classification(post_2) - - subject.bulk_classify!(Post.where(id: [post_1.id, post_2.id])) - - expect(ClassificationResult.where(target: post_1).count).to eq(expected_analysis) - expect(ClassificationResult.where(target: post_2).count).to eq(expected_analysis) - end - - it "classification results must be { emotion => score }" do - SentimentInferenceStubs.stub_classification(post_1) - SentimentInferenceStubs.stub_classification(post_2) - - subject.bulk_classify!(Post.where(id: [post_1.id, post_2.id])) - - check_classification_for(post_1) - check_classification_for(post_2) - end - - it "does nothing if there are no classification model" do - SiteSetting.ai_sentiment_model_configs = "" - - subject.bulk_classify!(Post.where(id: [post_1.id, post_2.id])) - - expect(ClassificationResult.where(target: post_1).count).to be_zero - expect(ClassificationResult.where(target: post_2).count).to be_zero - end - - it "don't reclassify everything when a model config changes" do - SentimentInferenceStubs.stub_classification(post_1) - - subject.bulk_classify!(Post.where(id: [post_1.id])) - first_classified_at = 2.days.ago - ClassificationResult.update_all(created_at: first_classified_at) - - current_models = JSON.parse(SiteSetting.ai_sentiment_model_configs) - current_models << { model_name: "new", endpoint: "https://test.com", api_key: "123" } - SiteSetting.ai_sentiment_model_configs = current_models.to_json - - SentimentInferenceStubs.stub_classification(post_1) - subject.bulk_classify!(Post.where(id: [post_1.id])) - - new_classifications = ClassificationResult.where("created_at > ?", first_classified_at).count - expect(new_classifications).to eq(1) - end - end - - describe ".backfill_query" do - it "excludes posts in personal messages" do - Fabricate(:private_message_post) - - posts = described_class.backfill_query - - expect(posts).to be_empty - end - - it "includes regular posts only" do - Fabricate(:small_action) - - posts = described_class.backfill_query - - expect(posts).to be_empty - end - - it "excludes posts from deleted topics" do - topic = Fabricate(:topic, deleted_at: 1.hour.ago) - Fabricate(:post, topic: topic) - - posts = described_class.backfill_query - - expect(posts).to be_empty - end - - it "includes topics if at least one configured model is missing" do - classified_post = Fabricate(:post) - current_models = JSON.parse(SiteSetting.ai_sentiment_model_configs) - current_models.each do |cm| - Fabricate(:classification_result, target: classified_post, model_used: cm["model_name"]) - end - - posts = described_class.backfill_query - expect(posts).not_to include(classified_post) - - current_models << { model_name: "new", endpoint: "htttps://test.com", api_key: "123" } - SiteSetting.ai_sentiment_model_configs = current_models.to_json - - posts = described_class.backfill_query - expect(posts).to contain_exactly(classified_post) - end - - it "excludes deleted posts" do - Fabricate(:post, deleted_at: 1.hour.ago) - - posts = described_class.backfill_query - - expect(posts).to be_empty - end - - context "with max_age_days" do - fab!(:age_post) { Fabricate(:post, created_at: 3.days.ago) } - - it "includes a post when is younger" do - posts = described_class.backfill_query(max_age_days: 4) - - expect(posts).to contain_exactly(age_post) - end - - it "excludes posts when it's older" do - posts = described_class.backfill_query(max_age_days: 2) - - expect(posts).to be_empty - end - end - - context "with from_post_id" do - fab!(:post) - - it "includes post if ID is higher" do - posts = described_class.backfill_query(from_post_id: post.id - 1) - - expect(posts).to contain_exactly(post) - end - - it "excludes post if ID is lower" do - posts = described_class.backfill_query(from_post_id: post.id + 1) - - expect(posts).to be_empty - end - end - end -end diff --git a/spec/lib/modules/summarization/entry_point_spec.rb b/spec/lib/modules/summarization/entry_point_spec.rb deleted file mode 100644 index 10f5b273..00000000 --- a/spec/lib/modules/summarization/entry_point_spec.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Summarization::EntryPoint do - before do - enable_current_plugin - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - SiteSetting.ai_summary_gists_enabled = true - end - - fab!(:user) - - describe "#inject_into" do - describe "hot topics gist summarization" do - fab!(:topic_ai_gist) - - before { TopicHotScore.create!(topic_id: topic_ai_gist.target_id, score: 1.0) } - - let(:topic_query) { TopicQuery.new(user) } - - describe "topic_query_create_list_topics modifier" do - context "when hot topic summarization is enabled" do - it "doesn't duplicate records when there more than one summary type" do - Fabricate(:ai_summary, target: topic_ai_gist.target) - - expect(topic_query.list_hot.topics.map(&:id)).to contain_exactly( - topic_ai_gist.target_id, - ) - end - - it "doesn't exclude records when the topic has a single different summary" do - regular_summary_2 = Fabricate(:ai_summary) - TopicHotScore.create!(topic_id: regular_summary_2.target_id, score: 1.0) - - expect(topic_query.list_hot.topics.map(&:id)).to contain_exactly( - regular_summary_2.target_id, - topic_ai_gist.target_id, - ) - end - - it "doesn't filter out hot topics without summaries" do - TopicHotScore.create!(topic_id: Fabricate(:topic).id, score: 1.0) - - expect(topic_query.list_hot.topics.size).to eq(2) - end - end - end - - describe "topic_list_item serializer's ai_summary" do - context "when hot topic summarization is disabled" do - before { SiteSetting.ai_summary_gists_enabled = false } - it "doesn't include summaries" do - gist_topic = topic_query.list_hot.topics.find { |t| t.id == topic_ai_gist.target_id } - - serialized = - TopicListItemSerializer.new(gist_topic, scope: Guardian.new, root: false).as_json - - expect(serialized.has_key?(:ai_topic_gist)).to eq(false) - end - end - - context "when hot topics summarization is enabled" do - fab!(:group) - - before do - group.add(user) - assign_persona_to(:ai_summary_gists_persona, [group.id]) - SiteSetting.ai_summary_gists_enabled = true - end - - it "includes the summary" do - gist_topic = topic_query.list_hot.topics.find { |t| t.id == topic_ai_gist.target_id } - - serialized = - TopicListItemSerializer.new( - gist_topic, - scope: Guardian.new(user), - root: false, - filter: :hot, - ).as_json - - expect(serialized[:ai_topic_gist]).to be_present - end - - it "doesn't include the summary when the user is not a member of the opt-in group" do - non_member_user = Fabricate(:user) - - gist_topic = topic_query.list_hot.topics.find { |t| t.id == topic_ai_gist.target_id } - - serialized = - TopicListItemSerializer.new( - gist_topic, - scope: Guardian.new(non_member_user), - root: false, - filter: :hot, - ).as_json - - expect(serialized[:ai_topic_gist]).to be_nil - end - - it "works when the topic has whispers" do - SiteSetting.whispers_allowed_groups = "#{Group::AUTO_GROUPS[:staff]}" - admin = Fabricate(:admin) - group.add(admin) - # We are testing a scenario where AR could get confused if we don't use `references`. - - first = create_post(raw: "this is the first post", title: "super amazing title") - - _whisper = - create_post( - topic_id: first.topic.id, - post_type: Post.types[:whisper], - raw: "this is a whispered reply", - ) - - Fabricate(:topic_ai_gist, target: first.topic) - topic_id = first.topic.id - TopicUser.update_last_read(admin, topic_id, first.post_number, 1, 1) - TopicUser.change( - admin.id, - topic_id, - notification_level: TopicUser.notification_levels[:tracking], - ) - - gist_topic = TopicQuery.new(admin).list_unread.topics.find { |t| t.id == topic_id } - - serialized = - TopicListItemSerializer.new( - gist_topic, - scope: Guardian.new(admin), - root: false, - filter: :unread, - ).as_json - - expect(serialized[:ai_topic_gist]).to be_present - end - - it "doesn't include the summary if it's not a gist" do - regular_summary_2 = Fabricate(:ai_summary) - TopicHotScore.create!(topic_id: regular_summary_2.target_id, score: 1.0) - - hot_topic = topic_query.list_hot.topics.find { |t| t.id == regular_summary_2.target_id } - - serialized = - TopicListItemSerializer.new( - hot_topic, - scope: Guardian.new(user), - root: false, - filter: :hot, - ).as_json - - expect(serialized[:ai_topic_gist]).to be_nil - end - end - end - end - end -end diff --git a/spec/lib/modules/summarization/fold_content_spec.rb b/spec/lib/modules/summarization/fold_content_spec.rb deleted file mode 100644 index 5c19077f..00000000 --- a/spec/lib/modules/summarization/fold_content_spec.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Summarization::FoldContent do - subject(:summarizer) { DiscourseAi::Summarization.topic_summary(topic) } - - let!(:llm_model) { assign_fake_provider_to(:ai_summarization_model) } - - fab!(:topic) { Fabricate(:topic, highest_post_number: 2) } - fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1, raw: "This is a text") } - - before do - enable_current_plugin - SiteSetting.ai_summarization_enabled = true - end - - describe "#summarize" do - before do - # Make sure each content fits in a single chunk. - # 700 is the number of tokens reserved for the prompt. - model_tokens = - 700 + - DiscourseAi::Tokenizer::OpenAiTokenizer.size( - "(1 #{post_1.user.username_lower} said: This is a text ", - ) + 3 - - llm_model.update!(max_prompt_tokens: model_tokens) - end - - let(:summary) { "this is a summary" } - - fab!(:user) - - it "summarizes the content" do - result = - DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do |spy| - summarizer.summarize(user).tap { expect(spy.completions).to eq(1) } - end - - expect(result.summarized_text).to eq(summary) - end - end - - describe "#existing_summary" do - context "when a summary already exists" do - fab!(:ai_summary) do - Fabricate( - :ai_summary, - target: topic, - highest_target_number: topic.highest_post_number, - original_content_sha: AiSummary.build_sha("1"), - ) - end - - it "doesn't mark it as outdated" do - expect(summarizer.existing_summary.outdated).to eq(false) - end - - context "when it's outdated because there are new targets" do - before { Fabricate(:post, topic: topic, post_number: 2, raw: "This is a text") } - - it "marks it as outdated" do - expect(summarizer.existing_summary.outdated).to eq(true) - end - end - - context "when it's outdated because existing content changes" do - it "marks it as outdated" do - ai_summary.update!(updated_at: 20.minutes.ago) - post_1.update!(last_version_at: 5.minutes.ago) - - expect(summarizer.existing_summary.outdated).to eq(true) - end - end - end - end -end diff --git a/spec/lib/modules/summarization/strategies/hot_topic_gists_spec.rb b/spec/lib/modules/summarization/strategies/hot_topic_gists_spec.rb deleted file mode 100644 index ab2cefa9..00000000 --- a/spec/lib/modules/summarization/strategies/hot_topic_gists_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Summarization::Strategies::HotTopicGists do - subject(:gist) { described_class.new(topic) } - - fab!(:topic) { Fabricate(:topic, highest_post_number: 25) } - fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) } - fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) } - - before { enable_current_plugin } - - describe "#targets_data" do - it "respects the `hot_topics_recent_days` setting" do - post_2.update(created_at: (SiteSetting.hot_topics_recent_days + 1).days.ago) - Fabricate(:post, topic: topic, post_number: 3) - - post_numbers = gist.targets_data.map { |c| c[:id] } - - expect(post_numbers).to contain_exactly(1, 3) - end - - it "only includes visible posts" do - post_2.update!(hidden: true) - - post_numbers = gist.targets_data.map { |c| c[:id] } - - expect(post_numbers).to contain_exactly(1) - end - - it "doesn't include posts without users" do - post_2.update!(user_id: nil) - - post_numbers = gist.targets_data.map { |c| c[:id] } - - expect(post_numbers).to contain_exactly(1) - end - - it "doesn't include whispers" do - post_2.update!(post_type: Post.types[:whisper]) - - post_numbers = gist.targets_data.map { |c| c[:id] } - - expect(post_numbers).to contain_exactly(1) - end - - context "when the topic has embed content cached" do - it "embed content is used instead of the raw text" do - topic_embed = - Fabricate( - :topic_embed, - topic: topic, - embed_content_cache: "

hello world new post :D

", - ) - - content = gist.targets_data - op_content = content.first[:text] - - expect(op_content).to include(topic_embed.embed_content_cache) - end - end - end -end diff --git a/spec/lib/modules/summarization/strategies/topic_summary_spec.rb b/spec/lib/modules/summarization/strategies/topic_summary_spec.rb deleted file mode 100644 index 80ee15b7..00000000 --- a/spec/lib/modules/summarization/strategies/topic_summary_spec.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Summarization::Strategies::TopicSummary do - subject(:topic_summary) { described_class.new(topic) } - - fab!(:topic) { Fabricate(:topic, highest_post_number: 25) } - fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) } - fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) } - - before { enable_current_plugin } - - describe "#targets_data" do - shared_examples "includes only public-visible topics" do - it "only includes visible posts" do - post_2.update!(hidden: true) - - post_numbers = topic_summary.targets_data.map { |c| c[:id] } - - expect(post_numbers).to contain_exactly(1) - end - - it "doesn't include posts without users" do - post_2.update!(user_id: nil) - - post_numbers = topic_summary.targets_data.map { |c| c[:id] } - - expect(post_numbers).to contain_exactly(1) - end - - it "doesn't include whispers" do - post_2.update!(post_type: Post.types[:whisper]) - - post_numbers = topic_summary.targets_data.map { |c| c[:id] } - - expect(post_numbers).to contain_exactly(1) - end - end - - context "when the topic has a best replies summary" do - before { topic.update(has_summary: true) } - - it_behaves_like "includes only public-visible topics" - end - - context "when the topic doesn't have a best replies summary" do - before { topic.update(has_summary: false) } - - it_behaves_like "includes only public-visible topics" - end - - context "when the topic has embed content cached" do - it "embed content is used instead of the raw text" do - topic_embed = - Fabricate( - :topic_embed, - topic: topic, - embed_content_cache: "

hello world new post :D

", - ) - - content = topic_summary.targets_data - op_content = content.first[:text] - - expect(op_content).to include(topic_embed.embed_content_cache) - end - end - - context "when enable_names enabled and prioritize_username_in_ux disabled" do - fab!(:user) { Fabricate(:user, name: "test") } - - it "includes the name" do - SiteSetting.enable_names = true - SiteSetting.prioritize_username_in_ux = false - - post_1.update!(user: user) - - content = topic_summary.targets_data - poster_name = content.first[:poster] - - expect(poster_name).to eq("test") - end - end - - context "when enable_names enabled and prioritize_username_in_ux enabled" do - fab!(:user) { Fabricate(:user, username: "test") } - - it "includes the username" do - SiteSetting.enable_names = true - SiteSetting.prioritize_username_in_ux = true - - post_1.update!(user: user) - - content = topic_summary.targets_data - poster_name = content.first[:poster] - - expect(poster_name).to eq("test") - end - end - end -end diff --git a/spec/lib/personas/artifact_update_strategies/diff_spec.rb b/spec/lib/personas/artifact_update_strategies/diff_spec.rb deleted file mode 100644 index 5f48f5bb..00000000 --- a/spec/lib/personas/artifact_update_strategies/diff_spec.rb +++ /dev/null @@ -1,244 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::ArtifactUpdateStrategies::Diff do - fab!(:user) - fab!(:post) - fab!(:artifact) { Fabricate(:ai_artifact) } - fab!(:llm_model) - - let(:llm) { llm_model.to_llm } - let(:instructions) { "Update the button color to red" } - - let(:strategy) do - described_class.new( - llm: llm, - post: post, - user: user, - artifact: artifact, - artifact_version: nil, - instructions: instructions, - ) - end - - before { enable_current_plugin } - - describe "#apply" do - it "processes simple search/replace blocks" do - original_css = ".button { color: blue; }" - artifact.update!(css: original_css) - - response = <<~RESPONSE - [CSS] - <<<<<<< SEARCH - .button { color: blue; } - ======= - .button { color: red; } - >>>>>>> REPLACE - [/CSS] - RESPONSE - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) { strategy.apply } - - expect(artifact.versions.last.css).to eq(".button { color: red; }") - end - - it "handles multiple search/replace blocks in the same section" do - original_css = <<~CSS - .button { color: blue; } - .text { font-size: 12px; } - CSS - - artifact.update!(css: original_css) - - response = <<~RESPONSE - [CSS] - <<<<<<< SEARCH - .button { color: blue; } - ======= - .button { color: red; } - >>>>>>> REPLACE - <<<<<<< SEARCH - .text { font-size: 12px; } - ======= - .text { font-size: 16px; } - >>>>>>> REPLACE - [/CSS] - RESPONSE - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) { strategy.apply } - - expected = <<~CSS.strip - .button { color: red; } - .text { font-size: 16px; } - CSS - - expect(artifact.versions.last.css.strip).to eq(expected.strip) - end - - it "handles non-contiguous search/replace using ..." do - original_css = <<~CSS - body { - color: red; - } - .button { - color: blue; - } - .alert { - background-color: green; - } - CSS - - artifact.update!(css: original_css) - - response = <<~RESPONSE - [CSS] - <<<<<<< SEARCH - body { - ... - background-color: green; - } - ======= - body { - color: red; - } - >>>>>>> REPLACE - [/CSS] - RESPONSE - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) { strategy.apply } - - expect(artifact.versions.last.css).to eq("body {\n color: red;\n}") - end - - it "can handle removal with blank blocks" do - original_css = <<~CSS - body { - color: red; - } - .button { - color: blue; - } - CSS - - artifact.update!(css: original_css) - - response = <<~RESPONSE - [CSS] - <<<<<<< SEARCH - body { - color: red; - } - ======= - >>>>>>> REPLACE - [/CSS] - RESPONSE - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) { strategy.apply } - - expect(artifact.versions.last.css.strip).to eq(".button {\n color: blue;\n}") - end - - it "tracks failed searches" do - original_css = ".button { color: blue; }" - artifact.update!(css: original_css) - - response = <<~RESPONSE - [CSS] - <<<<<<< SEARCH - .button { color: green; } - ======= - .button { color: red; } - >>>>>>> REPLACE - [/CSS] - RESPONSE - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) { strategy.apply } - - expect(strategy.failed_searches).to contain_exactly( - { section: :css, search: ".button { color: green; }" }, - ) - expect(artifact.versions.last.css).to eq(original_css) - end - - it "handles complete section replacements" do - original_html = "
old content
" - artifact.update!(html: original_html) - - response = <<~RESPONSE - [HTML] -
new content
- [/HTML] - RESPONSE - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) { strategy.apply } - - expect(artifact.versions.last.html.strip).to eq("
new content
") - end - - it "ignores empty or 'no changes' sections part 1" do - original = { - html: "
content
", - css: ".button { color: blue; }", - js: "console.log('test');", - } - - artifact.update!(html: original[:html], css: original[:css], js: original[:js]) - - response = <<~RESPONSE - [HTML] - no changes - [/HTML] - [CSS] - (NO CHANGES) - [/CSS] - [JavaScript] - <<<<<<< SEARCH - console.log('test'); - ======= - console.log('(no changes)'); - >>>>>>> REPLACE - [/JavaScript] - RESPONSE - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) { strategy.apply } - - version = artifact.versions.last - expect(version.html).to eq(original[:html]) - expect(version.css).to eq(original[:css]) - expect(version.js).to eq("console.log('(no changes)');") - end - - it "ignores empty or 'no changes' section part 2" do - original = { - html: "
content
", - css: ".button { color: blue; }", - js: "console.log('test');", - } - - artifact.update!(html: original[:html], css: original[:css], js: original[:js]) - - response = <<~RESPONSE - [HTML] - (no changes) - [/HTML] - [CSS] - - [/CSS] - [JavaScript] - <<<<<<< SEARCH - console.log('test'); - ======= - console.log('updated'); - >>>>>>> REPLACE - [/JavaScript] - RESPONSE - - DiscourseAi::Completions::Llm.with_prepared_responses([response]) { strategy.apply } - - version = artifact.versions.last - expect(version.html).to eq(original[:html]) - expect(version.css).to eq(original[:css]) - expect(version.js).to eq("console.log('updated');") - end - end -end diff --git a/spec/lib/personas/bot_spec.rb b/spec/lib/personas/bot_spec.rb deleted file mode 100644 index 247f0c62..00000000 --- a/spec/lib/personas/bot_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Bot do - subject(:bot) { described_class.as(bot_user, persona: DiscourseAi::Personas::General.new) } - - fab!(:admin) - fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") } - fab!(:fake) { Fabricate(:llm_model, name: "fake", provider: "fake") } - - before do - enable_current_plugin - toggle_enabled_bots(bots: [gpt_4]) - SiteSetting.ai_bot_enabled = true - end - - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(gpt_4.name) } - - let!(:user) { Fabricate(:user) } - - let(:function_call) { <<~TEXT } - Let me try using a function to get more info: - - categories - - - TEXT - - let(:response) { "As expected, your forum has multiple tags" } - - let(:llm_responses) { [function_call, response] } - - describe "#reply" do - it "sets top_p and temperature params" do - # full integration test so we have certainty it is passed through - - DiscourseAi::Completions::Endpoints::Fake.delays = [] - DiscourseAi::Completions::Endpoints::Fake.last_call = nil - - toggle_enabled_bots(bots: [fake]) - Group.refresh_automatic_groups! - - bot_user = DiscourseAi::AiBot::EntryPoint.find_user_from_model(fake.name) - AiPersona.create!( - name: "TestPersona", - top_p: 0.5, - temperature: 0.4, - system_prompt: "test", - description: "test", - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - ) - - personaClass = DiscourseAi::Personas::Persona.find_by(user: admin, name: "TestPersona") - - bot = described_class.as(bot_user, persona: personaClass.new) - bot.reply( - DiscourseAi::Personas::BotContext.new(messages: [{ type: :user, content: "test" }]), - ) do |_partial, _cancel, _placeholder| - # we just need the block so bot has something to call with results - end - - last_call = DiscourseAi::Completions::Endpoints::Fake.last_call - expect(last_call[:model_params][:top_p]).to eq(0.5) - expect(last_call[:model_params][:temperature]).to eq(0.4) - end - - context "when using function chaining" do - it "yields a loading placeholder while proceeds to invoke the command" do - tool = DiscourseAi::Personas::Tools::ListCategories.new({}, bot_user: nil, llm: nil) - partial_placeholder = +(<<~HTML) -
- #{tool.summary} -

-
- - - HTML - - context = - DiscourseAi::Personas::BotContext.new( - messages: [{ type: :user, content: "Does my site has tags?" }], - ) - - DiscourseAi::Completions::Llm.with_prepared_responses(llm_responses) do - bot.reply(context) do |_bot_reply_post, cancel, placeholder| - expect(placeholder).to eq(partial_placeholder) if placeholder - end - end - end - end - end -end diff --git a/spec/lib/personas/concept_deduplicator_spec.rb b/spec/lib/personas/concept_deduplicator_spec.rb deleted file mode 100644 index 9a1f36ab..00000000 --- a/spec/lib/personas/concept_deduplicator_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::ConceptDeduplicator do - let(:persona) { described_class.new } - - before { enable_current_plugin } - - describe ".default_enabled" do - it "is disabled by default" do - expect(described_class.default_enabled).to eq(false) - end - end - - describe "#system_prompt" do - let(:prompt) { persona.system_prompt } - - it "specifies output format" do - expect(prompt).to include("") - expect(prompt).to include("") - expect(prompt).to include('"streamlined_tags": ["tag1", "tag3"]') - expect(prompt).to include("") - end - end - - describe "#response_format" do - it "defines correct response format" do - format = persona.response_format - - expect(format).to eq( - [{ "array_type" => "string", "key" => "streamlined_tags", "type" => "array" }], - ) - end - end -end diff --git a/spec/lib/personas/concept_finder_spec.rb b/spec/lib/personas/concept_finder_spec.rb deleted file mode 100644 index 884fb0d9..00000000 --- a/spec/lib/personas/concept_finder_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::ConceptFinder do - let(:persona) { described_class.new } - - before { enable_current_plugin } - - describe ".default_enabled" do - it "is disabled by default" do - expect(described_class.default_enabled).to eq(false) - end - end - - describe "#system_prompt" do - before do - Fabricate(:inferred_concept, name: "programming") - Fabricate(:inferred_concept, name: "testing") - Fabricate(:inferred_concept, name: "ruby") - end - - it "includes existing concepts when available" do - prompt = persona.system_prompt - - InferredConcept.all.each { |concept| expect(prompt).to include(concept.name) } - end - - it "handles empty existing concepts" do - InferredConcept.destroy_all - prompt = persona.system_prompt - - expect(prompt).not_to include("following concepts already exist") - expect(prompt).to include("advanced concept tagging system") - end - - it "limits existing concepts to 100" do - manager = instance_double(DiscourseAi::InferredConcepts::Manager) - allow(DiscourseAi::InferredConcepts::Manager).to receive(:new).and_return(manager) - allow(manager).to receive(:list_concepts).with(limit: 100).and_return(%w[concept1 concept2]) - - persona.system_prompt - end - - it "includes format instructions" do - prompt = persona.system_prompt - - expect(prompt).to include("") - expect(prompt).to include('{"concepts": ["concept1", "concept2", "concept3"]}') - expect(prompt).to include("") - end - - it "includes language preservation instruction" do - prompt = persona.system_prompt - - expect(prompt).to include("original language of the text") - end - end - - describe "#response_format" do - it "defines correct response format" do - format = persona.response_format - - expect(format).to eq([{ "array_type" => "string", "key" => "concepts", "type" => "array" }]) - end - end -end diff --git a/spec/lib/personas/concept_matcher_spec.rb b/spec/lib/personas/concept_matcher_spec.rb deleted file mode 100644 index 955463ca..00000000 --- a/spec/lib/personas/concept_matcher_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::ConceptMatcher do - let(:persona) { described_class.new } - - before { enable_current_plugin } - - describe ".default_enabled" do - it "is disabled by default" do - expect(described_class.default_enabled).to eq(false) - end - end - - describe "#system_prompt" do - let(:prompt) { persona.system_prompt } - - it "includes placeholder for concept list" do - expect(prompt).to include("{inferred_concepts}") - end - - it "specifies output format" do - expect(prompt).to include("matching_concepts") - expect(prompt).to include("") - expect(prompt).to include('{"matching_concepts": ["concept1", "concept3", "concept5"]}') - expect(prompt).to include("") - end - end - - describe "#response_format" do - it "defines correct response format" do - format = persona.response_format - - expect(format).to eq( - [{ "array_type" => "string", "key" => "matching_concepts", "type" => "array" }], - ) - end - end -end diff --git a/spec/lib/personas/persona_spec.rb b/spec/lib/personas/persona_spec.rb deleted file mode 100644 index ca1ac6bb..00000000 --- a/spec/lib/personas/persona_spec.rb +++ /dev/null @@ -1,507 +0,0 @@ -#frozen_string_literal: true - -class TestPersona < DiscourseAi::Personas::Persona - def tools - [ - DiscourseAi::Personas::Tools::ListTags, - DiscourseAi::Personas::Tools::Search, - DiscourseAi::Personas::Tools::Image, - ] - end - - def system_prompt - <<~PROMPT - {site_url} - {site_title} - {site_description} - {participants} - {time} - {resource_url} - {inferred_concepts} - PROMPT - end -end - -RSpec.describe DiscourseAi::Personas::Persona do - let(:persona) { TestPersona.new } - - let(:topic_with_users) do - topic = Topic.new - topic.allowed_users = [User.new(username: "joe"), User.new(username: "jane")] - topic - end - - let(:resource_url) { "https://path-to-resource" } - let(:inferred_concepts) { %w[bulbassaur charmander squirtle].join(", ") } - - let(:context) do - DiscourseAi::Personas::BotContext.new( - site_url: Discourse.base_url, - site_title: "test site title", - site_description: "test site description", - time: Time.zone.now, - participants: topic_with_users.allowed_users.map(&:username).join(", "), - resource_url: resource_url, - inferred_concepts: inferred_concepts, - ) - end - - fab!(:admin) - fab!(:user) - fab!(:upload) - - before { enable_current_plugin } - - after do - # we are rolling back transactions so we can create poison cache - AiPersona.persona_cache.flush! - end - - it "renders the system prompt" do - freeze_time - - rendered = persona.craft_prompt(context) - system_message = rendered.messages.first[:content] - - expect(system_message).to include(Discourse.base_url) - expect(system_message).to include("test site title") - expect(system_message).to include("test site description") - expect(system_message).to include("joe, jane") - expect(system_message).to include(Time.zone.now.to_s) - expect(system_message).to include(resource_url) - expect(system_message).to include(inferred_concepts) - - tools = rendered.tools - - expect(tools.find { |t| t.name == "search" }).to be_present - expect(tools.find { |t| t.name == "tags" }).to be_present - - # needs to be configured so it is not available - expect(tools.find { |t| t.name == "image" }).to be_nil - end - - it "can parse string that are wrapped in quotes" do - SiteSetting.ai_stability_api_key = "123" - - tool_call = - DiscourseAi::Completions::ToolCall.new( - name: "image", - id: "call_JtYQMful5QKqw97XFsHzPweB", - parameters: { - prompts: ["cat oil painting", "big car"], - aspect_ratio: "16:9", - }, - ) - - tool_instance = - DiscourseAi::Personas::Artist.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil) - - expect(tool_instance.parameters[:prompts]).to eq(["cat oil painting", "big car"]) - expect(tool_instance.parameters[:aspect_ratio]).to eq("16:9") - end - - it "enforces enums" do - tool_call = - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "call_JtYQMful5QKqw97XFsHzPweB", - parameters: { - max_posts: "3.2", - status: "cow", - foo: "bar", - }, - ) - - tool_instance = - DiscourseAi::Personas::General.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil) - - expect(tool_instance.parameters.key?(:status)).to eq(false) - - tool_call = - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "call_JtYQMful5QKqw97XFsHzPweB", - parameters: { - max_posts: "3.2", - status: "open", - foo: "bar", - }, - ) - - tool_instance = - DiscourseAi::Personas::General.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil) - - expect(tool_instance.parameters[:status]).to eq("open") - end - - it "can coerce integers" do - tool_call = - DiscourseAi::Completions::ToolCall.new( - name: "search", - id: "call_JtYQMful5QKqw97XFsHzPweB", - parameters: { - max_posts: "3.2", - search_query: "hello world", - foo: "bar", - }, - ) - - search = - DiscourseAi::Personas::General.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil) - - expect(search.parameters[:max_posts]).to eq(3) - expect(search.parameters[:search_query]).to eq("hello world") - expect(search.parameters.key?(:foo)).to eq(false) - end - - it "can correctly parse arrays in tools" do - SiteSetting.ai_openai_api_key = "123" - - tool_call = - DiscourseAi::Completions::ToolCall.new( - name: "dall_e", - id: "call_JtYQMful5QKqw97XFsHzPweB", - parameters: { - prompts: ["cat oil painting", "big car"], - }, - ) - - tool_instance = - DiscourseAi::Personas::DallE3.new.find_tool(tool_call, bot_user: nil, llm: nil, context: nil) - expect(tool_instance.parameters[:prompts]).to eq(["cat oil painting", "big car"]) - end - - describe "custom personas" do - it "is able to find custom personas" do - Group.refresh_automatic_groups! - - # define an ai persona everyone can see - persona = - AiPersona.create!( - name: "zzzpun_bot", - description: "you write puns", - system_prompt: "you are pun bot", - tools: ["Image"], - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - ) - - custom_persona = DiscourseAi::Personas::Persona.all(user: user).last - expect(custom_persona.name).to eq("zzzpun_bot") - expect(custom_persona.description).to eq("you write puns") - - instance = custom_persona.new - expect(instance.tools).to eq([DiscourseAi::Personas::Tools::Image]) - expect(instance.craft_prompt(context).messages.first[:content]).to eq("you are pun bot") - - # should update - persona.update!(name: "zzzpun_bot2") - custom_persona = DiscourseAi::Personas::Persona.all(user: user).last - expect(custom_persona.name).to eq("zzzpun_bot2") - - # can be disabled - persona.update!(enabled: false) - last_persona = DiscourseAi::Personas::Persona.all(user: user).last - expect(last_persona.name).not_to eq("zzzpun_bot2") - - persona.update!(enabled: true) - # no groups have access - persona.update!(allowed_group_ids: []) - - last_persona = DiscourseAi::Personas::Persona.all(user: user).last - expect(last_persona.name).not_to eq("zzzpun_bot2") - end - end - - describe "available personas" do - it "includes all personas by default" do - Group.refresh_automatic_groups! - - # must be enabled to see it - SiteSetting.ai_stability_api_key = "abc" - SiteSetting.ai_google_custom_search_api_key = "abc" - SiteSetting.ai_google_custom_search_cx = "abc123" - - # should be ordered by priority and then alpha - expect(DiscourseAi::Personas::Persona.all(user: user).map(&:superclass)).to contain_exactly( - DiscourseAi::Personas::General, - DiscourseAi::Personas::Artist, - DiscourseAi::Personas::Creative, - DiscourseAi::Personas::DiscourseHelper, - DiscourseAi::Personas::GithubHelper, - DiscourseAi::Personas::Researcher, - DiscourseAi::Personas::SettingsExplorer, - DiscourseAi::Personas::SqlHelper, - ) - - # it should allow staff access to WebArtifactCreator - expect(DiscourseAi::Personas::Persona.all(user: admin).map(&:superclass)).to contain_exactly( - DiscourseAi::Personas::General, - DiscourseAi::Personas::Artist, - DiscourseAi::Personas::Creative, - DiscourseAi::Personas::DiscourseHelper, - DiscourseAi::Personas::GithubHelper, - DiscourseAi::Personas::Researcher, - DiscourseAi::Personas::SettingsExplorer, - DiscourseAi::Personas::SqlHelper, - DiscourseAi::Personas::WebArtifactCreator, - ) - - # omits personas if key is missing - SiteSetting.ai_stability_api_key = "" - SiteSetting.ai_google_custom_search_api_key = "" - SiteSetting.ai_artifact_security = "disabled" - - expect(DiscourseAi::Personas::Persona.all(user: admin).map(&:superclass)).to contain_exactly( - DiscourseAi::Personas::General, - DiscourseAi::Personas::SqlHelper, - DiscourseAi::Personas::SettingsExplorer, - DiscourseAi::Personas::Creative, - DiscourseAi::Personas::DiscourseHelper, - DiscourseAi::Personas::GithubHelper, - ) - - AiPersona.find( - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::General], - ).update!(enabled: false) - - expect(DiscourseAi::Personas::Persona.all(user: user).map(&:superclass)).to contain_exactly( - DiscourseAi::Personas::SqlHelper, - DiscourseAi::Personas::SettingsExplorer, - DiscourseAi::Personas::Creative, - DiscourseAi::Personas::DiscourseHelper, - DiscourseAi::Personas::GithubHelper, - ) - end - end - - describe "#craft_prompt" do - fab!(:vector_def) { Fabricate(:embedding_definition) } - - before do - Group.refresh_automatic_groups! - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_enabled = true - end - - let(:ai_persona) { DiscourseAi::Personas::Persona.all(user: user).first.new } - - let(:with_cc) do - context.messages = [{ content: "Tell me the time", type: :user }] - context - end - - context "when a persona has no uploads" do - it "doesn't include RAG guidance" do - guidance_fragment = - "The following texts will give you additional guidance to elaborate a response." - - expect(ai_persona.craft_prompt(with_cc).messages.first[:content]).not_to include( - guidance_fragment, - ) - end - end - - context "when RAG is running with a question consolidator" do - let(:consolidated_question) { "what is the time in france?" } - - fab!(:llm_model) { Fabricate(:fake_model) } - - fab!(:custom_ai_persona) do - Fabricate( - :ai_persona, - name: "custom", - rag_conversation_chunks: 3, - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - question_consolidator_llm_id: llm_model.id, - ) - end - - before do - context_embedding = vector_def.dimensions.times.map { rand(-1.0...1.0) } - EmbeddingsGenerationStubs.hugging_face_service(consolidated_question, context_embedding) - - UploadReference.ensure_exist!(target: custom_ai_persona, upload_ids: [upload.id]) - end - - it "will run the question consolidator" do - custom_persona = - DiscourseAi::Personas::Persona.find_by(id: custom_ai_persona.id, user: user).new - - # this means that we will consolidate - context.messages = [ - { content: "Tell me the time", type: :user }, - { content: "the time is 1", type: :model }, - { content: "in france?", type: :user }, - ] - - DiscourseAi::Completions::Endpoints::Fake.with_fake_content(consolidated_question) do - custom_persona.craft_prompt(context).messages.first[:content] - end - - message = - DiscourseAi::Completions::Endpoints::Fake.last_call[:dialect].prompt.messages.last[ - :content - ] - expect(message).to include("Tell me the time") - expect(message).to include("the time is 1") - expect(message).to include("in france?") - end - - context "when there are messages with uploads" do - let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } - let(:image_upload) do - UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) - end - - it "the question consolidator works" do - custom_persona = - DiscourseAi::Personas::Persona.find_by(id: custom_ai_persona.id, user: user).new - - context.messages = [ - { content: "Tell me the time", type: :user }, - { content: "the time is 1", type: :model }, - { content: ["in france?", { upload_id: image_upload.id }], type: :user }, - ] - - DiscourseAi::Completions::Endpoints::Fake.with_fake_content(consolidated_question) do - custom_persona.craft_prompt(context).messages.first[:content] - end - - message = - DiscourseAi::Completions::Endpoints::Fake.last_call[:dialect].prompt.messages.last[ - :content - ] - expect(message).to include("Tell me the time") - expect(message).to include("the time is 1") - expect(message).to include("in france?") - end - end - end - - context "when a persona has RAG uploads" do - let(:embedding_value) { 0.04381 } - let(:prompt_cc_embeddings) { [embedding_value] * vector_def.dimensions } - - def stub_fragments(fragment_count, persona: ai_persona) - schema = DiscourseAi::Embeddings::Schema.for(RagDocumentFragment) - - fragment_count.times do |i| - fragment = - Fabricate( - :rag_document_fragment, - fragment: "fragment-n#{i}", - target_id: persona.id, - target_type: "AiPersona", - upload: upload, - ) - - # Similarity is determined left-to-right. - embeddings = [embedding_value + "0.000#{i}".to_f] * vector_def.dimensions - - schema.store(fragment, embeddings, "test") - end - end - - before do - stored_ai_persona = AiPersona.find(ai_persona.id) - UploadReference.ensure_exist!(target: stored_ai_persona, upload_ids: [upload.id]) - - EmbeddingsGenerationStubs.hugging_face_service( - with_cc.messages.dig(0, :content), - prompt_cc_embeddings, - ) - end - - context "when persona allows for less fragments" do - it "will only pick 3 fragments" do - custom_ai_persona = - Fabricate( - :ai_persona, - name: "custom", - rag_conversation_chunks: 3, - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - ) - - stub_fragments(3, persona: custom_ai_persona) - - UploadReference.ensure_exist!(target: custom_ai_persona, upload_ids: [upload.id]) - - custom_persona = - DiscourseAi::Personas::Persona.find_by(id: custom_ai_persona.id, user: user).new - - expect(custom_persona.class.rag_conversation_chunks).to eq(3) - - crafted_system_prompt = custom_persona.craft_prompt(with_cc).messages.first[:content] - - expect(crafted_system_prompt).to include("fragment-n0") - expect(crafted_system_prompt).to include("fragment-n1") - expect(crafted_system_prompt).to include("fragment-n2") - expect(crafted_system_prompt).not_to include("fragment-n3") - end - end - - context "when the reranker is available" do - before do - SiteSetting.ai_hugging_face_tei_reranker_endpoint = "https://test.reranker.com" - stub_fragments(15) - end - - it "uses the re-ranker to reorder the fragments and pick the top 10 candidates" do - skip "This test is flaky needs to be investigated ordering does not come back as expected" - # The re-ranker reverses the similarity search, but return less results - # to act as a limit for test-purposes. - expected_reranked = (4..14).to_a.reverse.map { |idx| { index: idx } } - - WebMock.stub_request(:post, "https://test.reranker.com/rerank").to_return( - status: 200, - body: JSON.dump(expected_reranked), - ) - - crafted_system_prompt = ai_persona.craft_prompt(with_cc).messages.first[:content] - - expect(crafted_system_prompt).to include("fragment-n14") - expect(crafted_system_prompt).to include("fragment-n13") - expect(crafted_system_prompt).to include("fragment-n12") - expect(crafted_system_prompt).not_to include("fragment-n4") # Fragment #11 not included - end - end - - context "when the reranker is not available" do - before { stub_fragments(10) } - - it "picks the first 10 candidates from the similarity search" do - crafted_system_prompt = ai_persona.craft_prompt(with_cc).messages.first[:content] - - expect(crafted_system_prompt).to include("fragment-n0") - expect(crafted_system_prompt).to include("fragment-n1") - expect(crafted_system_prompt).to include("fragment-n2") - - expect(crafted_system_prompt).not_to include("fragment-n10") # Fragment #10 not included - end - end - - context "when the persona has examples" do - fab!(:examples_persona) do - Fabricate( - :ai_persona, - examples: [["User message", "assistant response"]], - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - ) - end - - it "includes them before the context messages" do - custom_persona = - DiscourseAi::Personas::Persona.find_by(id: examples_persona.id, user: user).new - - post_system_prompt_msgs = custom_persona.craft_prompt(with_cc).messages.last(3) - - expect(post_system_prompt_msgs).to contain_exactly( - { content: "User message", type: :user }, - { content: "assistant response", type: :model }, - { content: "Tell me the time", type: :user }, - ) - end - end - end - end -end diff --git a/spec/lib/personas/question_consolidator_spec.rb b/spec/lib/personas/question_consolidator_spec.rb deleted file mode 100644 index 20a7b573..00000000 --- a/spec/lib/personas/question_consolidator_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::QuestionConsolidator do - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{Fabricate(:fake_model).id}") } - let(:fake_endpoint) { DiscourseAi::Completions::Endpoints::Fake } - - fab!(:user) - - before { enable_current_plugin } - - describe ".consolidate_question" do - it "properly picks all the right messages and consolidates" do - messages = [ - { type: :user, content: "What is the capital of France?" }, - { type: :tool_call, content: "search:google", id: "123" }, - { type: :tool, content: "some results from google", id: "123" }, - { type: :model, content: "Paris" }, - { type: :user, content: "What about Germany?" }, - ] - - result = described_class.consolidate_question(llm, messages, user) - expect(result).to eq(fake_endpoint.fake_content) - - call = fake_endpoint.last_call - - prompt = call[:dialect].prompt - expect(prompt.messages.length).to eq(2) - content = prompt.messages[1][:content] - expect(content).to include("Germany") - expect(content).to include("France") - expect(content).to include("Paris") - expect(content).not_to include("google") - end - end -end diff --git a/spec/lib/personas/researcher_spec.rb b/spec/lib/personas/researcher_spec.rb deleted file mode 100644 index cf4f29fe..00000000 --- a/spec/lib/personas/researcher_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Researcher do - let(:researcher) { subject } - - before { enable_current_plugin } - - it "renders schema" do - expect(researcher.tools).to eq( - [DiscourseAi::Personas::Tools::Google, DiscourseAi::Personas::Tools::WebBrowser], - ) - end -end diff --git a/spec/lib/personas/settings_explorer_spec.rb b/spec/lib/personas/settings_explorer_spec.rb deleted file mode 100644 index 1f700ed4..00000000 --- a/spec/lib/personas/settings_explorer_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::SettingsExplorer do - let(:settings_explorer) { subject } - - before { enable_current_plugin } - - it "renders schema" do - prompt = settings_explorer.system_prompt - - # check we do not render plugin settings - expect(prompt).not_to include("ai_bot_enabled_personas") - - expect(prompt).to include("site_description") - - expect(settings_explorer.tools).to eq( - [DiscourseAi::Personas::Tools::SettingContext, DiscourseAi::Personas::Tools::SearchSettings], - ) - end -end diff --git a/spec/lib/personas/sql_helper_spec.rb b/spec/lib/personas/sql_helper_spec.rb deleted file mode 100644 index fe86ad03..00000000 --- a/spec/lib/personas/sql_helper_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::SqlHelper do - let(:sql_helper) { subject } - - before { enable_current_plugin } - - it "renders schema" do - prompt = sql_helper.system_prompt - expect(prompt).to include("posts(") - expect(prompt).to include("topics(") - expect(prompt).not_to include("translation_key") # not a priority table - expect(prompt).to include("user_api_keys") # not a priority table - - expect(sql_helper.tools).to eq([DiscourseAi::Personas::Tools::DbSchema]) - end -end diff --git a/spec/lib/personas/tools/create_artifact_spec.rb b/spec/lib/personas/tools/create_artifact_spec.rb deleted file mode 100644 index 1ca40156..00000000 --- a/spec/lib/personas/tools/create_artifact_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::CreateArtifact do - fab!(:llm_model) - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - fab!(:post) - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#process" do - it "correctly adds details block on final invoke" do - responses = [<<~TXT.strip] - [HTML] -
- hello -
- [/HTML] - [CSS] - .hello { - color: red; - } - [/CSS] - [JavaScript] - console.log("hello"); - console.log("world"); - [/JavaScript] - TXT - - tool = nil - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - tool = - described_class.new( - { html_body: "hello" }, - bot_user: Fabricate(:user), - llm: llm, - context: DiscourseAi::Personas::BotContext.new(post: post), - ) - - tool.parameters = { name: "hello", specification: "hello spec" } - - tool.invoke {} - end - - artifact_id = AiArtifact.order("id desc").limit(1).pluck(:id).first - - expected = <<~MD - [details="View Source"] - ### HTML - ```html -
- hello -
- ``` - - ### CSS - ```css - .hello { - color: red; - } - ``` - - ### JavaScript - ```javascript - console.log("hello"); - console.log("world"); - ``` - [/details] - - ### Preview -
- MD - expect(tool.custom_raw.strip).to eq(expected.strip) - end - end -end diff --git a/spec/lib/personas/tools/create_image_spec.rb b/spec/lib/personas/tools/create_image_spec.rb deleted file mode 100644 index 9ce051eb..00000000 --- a/spec/lib/personas/tools/create_image_spec.rb +++ /dev/null @@ -1,139 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::CreateImage do - let(:prompts) { ["a watercolor painting", "an abstract design"] } - - fab!(:gpt_35_turbo) { Fabricate(:llm_model, name: "gpt-3.5-turbo") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_35_turbo]) - SiteSetting.ai_openai_api_key = "abc" - end - - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(gpt_35_turbo.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{gpt_35_turbo.id}") } - let(:progress_blk) { Proc.new {} } - - let(:create_image) { described_class.new({ prompts: prompts }, llm: llm, bot_user: bot_user) } - - let(:base64_image) do - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - end - - describe "#process" do - it "can reject generation of images and return a proper error to llm" do - error_message = { - error: { - message: - "Your request was rejected as a result of our safety system. Your request may contain content that is not allowed by our safety system.", - type: "user_error", - param: nil, - code: "moderation_blocked", - }, - } - - WebMock.stub_request(:post, "https://api.openai.com/v1/images/generations").to_return( - status: 400, - body: error_message.to_json, - ) - - info = create_image.invoke(&progress_blk).to_json - expect(info).to include("Your request was rejected as a result of our safety system.") - expect(create_image.chain_next_response?).to eq(true) - end - it "can generate images with gpt-image-1 model" do - data = [{ b64_json: base64_image, revised_prompt: "a watercolor painting of flowers" }] - - WebMock - .stub_request(:post, "https://api.openai.com/v1/images/generations") - .with do |request| - json = JSON.parse(request.body, symbolize_names: true) - - expect(prompts).to include(json[:prompt]) - expect(json[:model]).to eq("gpt-image-1") - expect(json[:size]).to eq("auto") - true - end - .to_return(status: 200, body: { data: data }.to_json) - - info = create_image.invoke(&progress_blk).to_json - - expect(JSON.parse(info)).to eq( - { - "prompts" => [ - { - "prompt" => "a watercolor painting of flowers", - "url" => "upload://pv9zsrM93Jz3U8xELTJCPYU2DD0.png", - }, - { - "prompt" => "a watercolor painting of flowers", - "url" => "upload://pv9zsrM93Jz3U8xELTJCPYU2DD0.png", - }, - ], - }, - ) - expect(create_image.custom_raw).to include("upload://") - expect(create_image.custom_raw).to include("[grid]") - expect(create_image.custom_raw).to include("a watercolor painting of flowers") - end - - it "can defaults to auto size" do - create_image_with_size = - described_class.new({ prompts: ["a landscape"] }, llm: llm, bot_user: bot_user) - - data = [{ b64_json: base64_image, revised_prompt: "a detailed landscape" }] - - WebMock - .stub_request(:post, "https://api.openai.com/v1/images/generations") - .with do |request| - json = JSON.parse(request.body, symbolize_names: true) - - expect(json[:prompt]).to eq("a landscape") - expect(json[:size]).to eq("auto") - true - end - .to_return(status: 200, body: { data: data }.to_json) - - info = create_image_with_size.invoke(&progress_blk).to_json - expect(JSON.parse(info)).to eq( - "prompts" => [ - { - "prompt" => "a detailed landscape", - "url" => "upload://pv9zsrM93Jz3U8xELTJCPYU2DD0.png", - }, - ], - ) - end - - it "handles custom API endpoint" do - SiteSetting.ai_openai_image_generation_url = "https://custom-api.example.com/images/generate" - - data = [{ b64_json: base64_image, revised_prompt: "a watercolor painting" }] - - WebMock - .stub_request(:post, SiteSetting.ai_openai_image_generation_url) - .with do |request| - json = JSON.parse(request.body, symbolize_names: true) - expect(prompts).to include(json[:prompt]) - true - end - .to_return(status: 200, body: { data: data }.to_json) - - info = create_image.invoke(&progress_blk).to_json - expect(JSON.parse(info)).to eq( - "prompts" => [ - { - "prompt" => "a watercolor painting", - "url" => "upload://pv9zsrM93Jz3U8xELTJCPYU2DD0.png", - }, - { - "prompt" => "a watercolor painting", - "url" => "upload://pv9zsrM93Jz3U8xELTJCPYU2DD0.png", - }, - ], - ) - end - end -end diff --git a/spec/lib/personas/tools/dall_e_spec.rb b/spec/lib/personas/tools/dall_e_spec.rb deleted file mode 100644 index 303c05d1..00000000 --- a/spec/lib/personas/tools/dall_e_spec.rb +++ /dev/null @@ -1,97 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::DallE do - let(:prompts) { ["a pink cow", "a red cow"] } - - fab!(:gpt_35_turbo) { Fabricate(:llm_model, name: "gpt-3.5-turbo") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_35_turbo]) - SiteSetting.ai_openai_api_key = "abc" - end - - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(gpt_35_turbo.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{gpt_35_turbo.id}") } - let(:progress_blk) { Proc.new {} } - - let(:dall_e) { described_class.new({ prompts: prompts }, llm: llm, bot_user: bot_user) } - - let(:base64_image) do - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - end - - describe "#process" do - it "can generate tall images" do - generator = - described_class.new( - { prompts: ["a cat"], aspect_ratio: "tall" }, - llm: llm, - bot_user: bot_user, - ) - - data = [{ b64_json: base64_image, revised_prompt: "a tall cat" }] - - WebMock - .stub_request(:post, "https://api.openai.com/v1/images/generations") - .with do |request| - json = JSON.parse(request.body, symbolize_names: true) - - expect(json[:prompt]).to eq("a cat") - expect(json[:size]).to eq("1024x1792") - true - end - .to_return(status: 200, body: { data: data }.to_json) - - info = generator.invoke(&progress_blk).to_json - expect(JSON.parse(info)).to eq("prompts" => ["a tall cat"]) - end - - it "can generate correct info with azure" do - _post = Fabricate(:post) - - SiteSetting.ai_openai_image_generation_url = "https://test.azure.com/some_url" - - data = [{ b64_json: base64_image, revised_prompt: "a pink cow 1" }] - - WebMock - .stub_request(:post, SiteSetting.ai_openai_image_generation_url) - .with do |request| - json = JSON.parse(request.body, symbolize_names: true) - - expect(prompts).to include(json[:prompt]) - expect(request.headers["Api-Key"]).to eq("abc") - true - end - .to_return(status: 200, body: { data: data }.to_json) - - info = dall_e.invoke(&progress_blk).to_json - - expect(JSON.parse(info)).to eq("prompts" => ["a pink cow 1", "a pink cow 1"]) - expect(dall_e.custom_raw).to include("upload://") - expect(dall_e.custom_raw).to include("[grid]") - expect(dall_e.custom_raw).to include("a pink cow 1") - end - - it "can generate correct info" do - data = [{ b64_json: base64_image, revised_prompt: "a pink cow 1" }] - - WebMock - .stub_request(:post, "https://api.openai.com/v1/images/generations") - .with do |request| - json = JSON.parse(request.body, symbolize_names: true) - expect(prompts).to include(json[:prompt]) - true - end - .to_return(status: 200, body: { data: data }.to_json) - - info = dall_e.invoke(&progress_blk).to_json - - expect(JSON.parse(info)).to eq("prompts" => ["a pink cow 1", "a pink cow 1"]) - expect(dall_e.custom_raw).to include("upload://") - expect(dall_e.custom_raw).to include("[grid]") - expect(dall_e.custom_raw).to include("a pink cow 1") - end - end -end diff --git a/spec/lib/personas/tools/db_schema_spec.rb b/spec/lib/personas/tools/db_schema_spec.rb deleted file mode 100644 index af7f12ef..00000000 --- a/spec/lib/personas/tools/db_schema_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::DbSchema do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#process" do - it "returns rich schema for tables" do - result = described_class.new({ tables: "posts,topics" }, bot_user: bot_user, llm: llm).invoke - - expect(result[:schema_info]).to include("raw text") - expect(result[:schema_info]).to include("views integer") - expect(result[:schema_info]).to include("posts") - expect(result[:schema_info]).to include("topics") - - expect(result[:tables]).to eq("posts,topics") - end - end -end diff --git a/spec/lib/personas/tools/discourse_meta_search_spec.rb b/spec/lib/personas/tools/discourse_meta_search_spec.rb deleted file mode 100644 index e343aa1c..00000000 --- a/spec/lib/personas/tools/discourse_meta_search_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true -RSpec.describe DiscourseAi::Personas::Tools::DiscourseMetaSearch do - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - fab!(:llm_model) { Fabricate(:llm_model, max_prompt_tokens: 8192) } - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:progress_blk) { Proc.new {} } - - let(:mock_search_json) { plugin_file_from_fixtures("search.json", "search_meta").read } - - let(:mock_search_with_categories_json) do - plugin_file_from_fixtures("search_with_categories.json", "search_meta").read - end - - let(:mock_site_json) { plugin_file_from_fixtures("site.json", "search_meta").read } - - before do - stub_request(:get, "https://meta.discourse.org/site.json").to_return( - status: 200, - body: mock_site_json, - headers: { - }, - ) - end - - it "searches meta.discourse.org" do - stub_request(:get, "https://meta.discourse.org/search.json?q=test").to_return( - status: 200, - body: mock_search_json, - headers: { - }, - ) - - search = described_class.new({ search_query: "test" }, bot_user: bot_user, llm: llm) - results = search.invoke(&progress_blk) - expect(results[:rows].length).to eq(20) - - expect(results[:rows].first[results[:column_names].index("category")]).to eq( - "documentation > developers", - ) - end - - it "searches meta.discourse.org with lazy_load_categories enabled" do - stub_request(:get, "https://meta.discourse.org/search.json?q=test").to_return( - status: 200, - body: mock_search_with_categories_json, - headers: { - }, - ) - - search = described_class.new({ search_query: "test" }, bot_user: bot_user, llm: llm) - results = search.invoke(&progress_blk) - expect(results[:rows].length).to eq(20) - - expect(results[:rows].first[results[:column_names].index("category")]).to eq( - "documentation > developers", - ) - end - - it "passes on all search parameters" do - url = - "https://meta.discourse.org/search.json?q=test%20category:test%20user:test%20order:test%20max_posts:1%20tags:test%20before:test%20after:test%20status:test" - - stub_request(:get, url).to_return(status: 200, body: mock_search_json, headers: {}) - params = - described_class.signature[:parameters] - .map do |param| - if param[:type] == "integer" - [param[:name], 1] - else - [param[:name], "test"] - end - end - .to_h - .symbolize_keys - - search = described_class.new(params, bot_user: bot_user, llm: llm) - results = search.invoke(&progress_blk) - - expect(results[:args]).to eq(params) - end -end diff --git a/spec/lib/personas/tools/edit_image_spec.rb b/spec/lib/personas/tools/edit_image_spec.rb deleted file mode 100644 index 2404a81c..00000000 --- a/spec/lib/personas/tools/edit_image_spec.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::EditImage do - fab!(:gpt_35_turbo) { Fabricate(:llm_model, name: "gpt-3.5-turbo") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_35_turbo]) - SiteSetting.ai_openai_api_key = "abc" - end - - let(:image_upload) do - UploadCreator.new( - File.open(Rails.root.join("spec/fixtures/images/smallest.png")), - "smallest.png", - ).create_for(Discourse.system_user.id) - end - - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(gpt_35_turbo.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{gpt_35_turbo.id}") } - let(:progress_blk) { Proc.new {} } - - let(:prompt) { "add a rainbow in the background" } - - let(:edit_image) do - described_class.new( - { image_urls: [image_upload.short_url], prompt: prompt }, - llm: llm, - bot_user: bot_user, - ) - end - - let(:base64_image) do - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - end - - describe "#process" do - it "can reject generation of images and return a proper error to llm" do - error_message = { - error: { - message: - "Your request was rejected as a result of our safety system. Your request may contain content that is not allowed by our safety system.", - type: "user_error", - param: nil, - code: "moderation_blocked", - }, - } - - WebMock.stub_request(:post, "https://api.openai.com/v1/images/edits").to_return( - status: 400, - body: error_message.to_json, - ) - - info = edit_image.invoke(&progress_blk).to_json - expect(info).to include("Your request was rejected as a result of our safety system.") - expect(edit_image.chain_next_response?).to eq(true) - end - - it "can edit an image with the GPT image model" do - data = [{ b64_json: base64_image, revised_prompt: "image with rainbow added in background" }] - - # Stub the OpenAI API call - WebMock - .stub_request(:post, "https://api.openai.com/v1/images/edits") - .with do |request| - # The request is multipart/form-data, so we can't easily parse the body - # Just check that the request was made to the right endpoint - expect(request.headers["Content-Type"]).to include("multipart/form-data") - true - end - .to_return(status: 200, body: { data: data }.to_json) - - info = edit_image.invoke(&progress_blk).to_json - - expect(JSON.parse(info)).to eq( - { - "prompt" => "image with rainbow added in background", - "url" => "upload://pv9zsrM93Jz3U8xELTJCPYU2DD0.png", - }, - ) - expect(edit_image.custom_raw).to include("upload://") - expect(edit_image.custom_raw).to include("![image with rainbow added in background]") - end - - it "handles custom API endpoint" do - SiteSetting.ai_openai_image_edit_url = "https://custom-api.example.com/images/edit" - - data = [{ b64_json: base64_image, revised_prompt: "image with rainbow added" }] - - # Stub the custom API endpoint - WebMock - .stub_request(:post, SiteSetting.ai_openai_image_edit_url) - .with do |request| - expect(request.headers["Content-Type"]).to include("multipart/form-data") - true - end - .to_return(status: 200, body: { data: data }.to_json) - - info = edit_image.invoke(&progress_blk).to_json - - expect(JSON.parse(info)).to eq( - { - "prompt" => "image with rainbow added", - "url" => "upload://pv9zsrM93Jz3U8xELTJCPYU2DD0.png", - }, - ) - end - end -end diff --git a/spec/lib/personas/tools/github_file_content_spec.rb b/spec/lib/personas/tools/github_file_content_spec.rb deleted file mode 100644 index fa01e34a..00000000 --- a/spec/lib/personas/tools/github_file_content_spec.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Personas::Tools::GithubFileContent do - fab!(:llm_model) - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - - let(:tool) do - described_class.new( - { - repo_name: "discourse/discourse-ai", - file_paths: %w[lib/database/connection.rb lib/ai_bot/tools/github_pull_request_diff.rb], - branch: "8b382d6098fde879d28bbee68d3cbe0a193e4ffc", - }, - bot_user: nil, - llm: llm, - ) - end - - before { enable_current_plugin } - - describe "#invoke" do - before do - stub_request( - :get, - "https://api.github.com/repos/discourse/discourse-ai/contents/lib/database/connection.rb?ref=8b382d6098fde879d28bbee68d3cbe0a193e4ffc", - ).to_return( - status: 200, - body: { content: Base64.encode64("content of connection.rb") }.to_json, - ) - - stub_request( - :get, - "https://api.github.com/repos/discourse/discourse-ai/contents/lib/ai_bot/tools/github_pull_request_diff.rb?ref=8b382d6098fde879d28bbee68d3cbe0a193e4ffc", - ).to_return( - status: 200, - body: { content: Base64.encode64("content of github_pull_request_diff.rb") }.to_json, - ) - end - - it "retrieves the content of the specified GitHub files" do - result = tool.invoke - expected = { - file_contents: - "File Path: lib/database/connection.rb:\ncontent of connection.rb\nFile Path: lib/ai_bot/tools/github_pull_request_diff.rb:\ncontent of github_pull_request_diff.rb", - } - - expect(result).to eq(expected) - end - end - - describe ".signature" do - it "returns the tool signature" do - signature = described_class.signature - expect(signature[:name]).to eq("github_file_content") - expect(signature[:description]).to eq("Retrieves the content of specified GitHub files") - expect(signature[:parameters]).to eq( - [ - { - name: "repo_name", - description: "The name of the GitHub repository (e.g., 'discourse/discourse')", - type: "string", - required: true, - }, - { - name: "file_paths", - description: "The paths of the files to retrieve within the repository", - type: "array", - item_type: "string", - required: true, - }, - { - name: "branch", - description: "The branch or commit SHA to retrieve the files from (default: 'main')", - type: "string", - required: false, - }, - ], - ) - end - end -end diff --git a/spec/lib/personas/tools/github_pull_request_diff_spec.rb b/spec/lib/personas/tools/github_pull_request_diff_spec.rb deleted file mode 100644 index c2e610e9..00000000 --- a/spec/lib/personas/tools/github_pull_request_diff_spec.rb +++ /dev/null @@ -1,150 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Personas::Tools::GithubPullRequestDiff do - let(:bot_user) { Fabricate(:user) } - fab!(:llm_model) - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:tool) { described_class.new({ repo: repo, pull_id: pull_id }, bot_user: bot_user, llm: llm) } - - before { enable_current_plugin } - - context "with #sort_and_shorten_diff" do - it "sorts and shortens the diff without dropping data" do - diff = <<~DIFF - diff --git a/src/lib.rs b/src/lib.rs - index b466edd..66b068f 100644 - --- a/src/lib.rs - +++ b/src/lib.rs - this is a longer diff - this is a longer diff - this is a longer diff - diff --git a/tests/test_encoding.py b/tests/test_encoding.py - index 27b2192..9f31319 100644 - --- a/tests/test_encoding.py - +++ b/tests/test_encoding.py - short diff - DIFF - - sorted_diff = described_class.sort_and_shorten_diff(diff) - - expect(sorted_diff).to eq(<<~DIFF) - diff --git a/tests/test_encoding.py b/tests/test_encoding.py - index 27b2192..9f31319 100644 - --- a/tests/test_encoding.py - +++ b/tests/test_encoding.py - short diff - - diff --git a/src/lib.rs b/src/lib.rs - index b466edd..66b068f 100644 - --- a/src/lib.rs - +++ b/src/lib.rs - this is a longer diff - this is a longer diff - this is a longer diff - DIFF - end - end - - context "with a valid pull request" do - let(:repo) { "discourse/discourse-automation" } - let(:pull_id) { 253 } - let(:diff) { <<~DIFF } - diff --git a/lib/discourse_automation/automation.rb b/lib/discourse_automation/automation.rb - index 3e3e3e3..4f4f4f4 100644 - --- a/lib/discourse_automation/automation.rb - +++ b/lib/discourse_automation/automation.rb - @@ -1,3 +1,3 @@ - -module DiscourseAutomation - DIFF - - let(:pr_info) do - { - "title" => "Test PR", - "state" => "open", - "user" => { - "login" => "test-user", - }, - "created_at" => "2023-01-01T00:00:00Z", - "updated_at" => "2023-01-02T00:00:00Z", - "head" => { - "repo" => { - "full_name" => "test/repo", - }, - "ref" => "feature-branch", - "sha" => "abc123", - }, - "base" => { - "repo" => { - "full_name" => "main/repo", - }, - "ref" => "main", - }, - } - end - - it "retrieves both PR info and diff" do - stub_request(:get, "https://api.github.com/repos/#{repo}/pulls/#{pull_id}").with( - headers: { - "Accept" => "application/json", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - }, - ).to_return(status: 200, body: pr_info.to_json) - - stub_request(:get, "https://api.github.com/repos/#{repo}/pulls/#{pull_id}").with( - headers: { - "Accept" => "application/vnd.github.v3.diff", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - }, - ).to_return(status: 200, body: diff) - - result = tool.invoke - expect(result[:diff]).to eq(diff) - expect(result[:pr_info]).to include(title: "Test PR", state: "open", author: "test-user") - expect(result[:error]).to be_nil - end - - it "uses the github access token if present" do - SiteSetting.ai_bot_github_access_token = "ABC" - - stub_request(:get, "https://api.github.com/repos/#{repo}/pulls/#{pull_id}").with( - headers: { - "Accept" => "application/json", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - "Authorization" => "Bearer ABC", - }, - ).to_return(status: 200, body: pr_info.to_json) - - stub_request(:get, "https://api.github.com/repos/#{repo}/pulls/#{pull_id}").with( - headers: { - "Accept" => "application/vnd.github.v3.diff", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - "Authorization" => "Bearer ABC", - }, - ).to_return(status: 200, body: diff) - - result = tool.invoke - expect(result[:diff]).to eq(diff) - expect(result[:error]).to be_nil - end - end - - context "with an invalid pull request" do - let(:repo) { "invalid/repo" } - let(:pull_id) { 999 } - - it "returns an error message" do - stub_request(:get, "https://api.github.com/repos/#{repo}/pulls/#{pull_id}").with( - headers: { - "Accept" => "application/json", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - }, - ).to_return(status: 404) - - result = tool.invoke - expect(result[:diff]).to be_nil - expect(result[:error]).to include("Failed to retrieve the PR information") - end - end -end diff --git a/spec/lib/personas/tools/github_search_code_spec.rb b/spec/lib/personas/tools/github_search_code_spec.rb deleted file mode 100644 index acf9ddbf..00000000 --- a/spec/lib/personas/tools/github_search_code_spec.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Personas::Tools::GithubSearchCode do - let(:bot_user) { Fabricate(:user) } - fab!(:llm_model) - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:tool) { described_class.new({ repo: repo, query: query }, bot_user: bot_user, llm: llm) } - - before { enable_current_plugin } - - context "with valid search results" do - let(:repo) { "discourse/discourse" } - let(:query) { "def hello" } - - it "searches for code in the repository" do - stub_request( - :get, - "https://api.github.com/search/code?q=def%20hello+repo:discourse/discourse", - ).with( - headers: { - "Accept" => "application/vnd.github.v3.text-match+json", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - }, - ).to_return( - status: 200, - body: { - total_count: 1, - items: [ - { - path: "test/hello.rb", - name: "hello.rb", - text_matches: [{ fragment: "def hello\n puts 'hello'\nend" }], - }, - ], - }.to_json, - ) - - result = tool.invoke - expect(result[:search_results]).to include("def hello\n puts 'hello'\nend") - expect(result[:search_results]).to include("test/hello.rb") - expect(result[:error]).to be_nil - end - end - - context "with an empty search result" do - let(:repo) { "discourse/discourse" } - let(:query) { "nonexistent_method" } - - describe "#description_args" do - it "returns the repo and query" do - expect(tool.description_args).to eq(repo: repo, query: query) - end - end - - it "returns an empty result" do - SiteSetting.ai_bot_github_access_token = "ABC" - stub_request( - :get, - "https://api.github.com/search/code?q=nonexistent_method+repo:discourse/discourse", - ).with( - headers: { - "Accept" => "application/vnd.github.v3.text-match+json", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - "Authorization" => "Bearer ABC", - }, - ).to_return(status: 200, body: { total_count: 0, items: [] }.to_json) - - result = tool.invoke - expect(result[:search_results]).to be_empty - expect(result[:error]).to be_nil - end - end - - context "with an error response" do - let(:repo) { "discourse/discourse" } - let(:query) { "def hello" } - - it "returns an error message" do - stub_request( - :get, - "https://api.github.com/search/code?q=def%20hello+repo:discourse/discourse", - ).with( - headers: { - "Accept" => "application/vnd.github.v3.text-match+json", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - }, - ).to_return(status: 403) - - result = tool.invoke - expect(result[:search_results]).to be_nil - expect(result[:error]).to include("Failed to perform code search") - end - end -end diff --git a/spec/lib/personas/tools/github_search_files_spec.rb b/spec/lib/personas/tools/github_search_files_spec.rb deleted file mode 100644 index 4c1bf643..00000000 --- a/spec/lib/personas/tools/github_search_files_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Personas::Tools::GithubSearchFiles do - fab!(:llm_model) - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - - let(:tool) do - described_class.new( - { - repo: "discourse/discourse-ai", - keywords: %w[search tool], - branch: nil, # Let it find the default branch - }, - bot_user: nil, - llm: llm, - ) - end - - before { enable_current_plugin } - - describe "#invoke" do - let(:default_branch) { "main" } - - before do - # Stub request to get the default branch - stub_request(:get, "https://api.github.com/repos/discourse/discourse-ai").to_return( - status: 200, - body: { default_branch: default_branch }.to_json, - ) - - # Stub request to get the file tree - stub_request( - :get, - "https://api.github.com/repos/discourse/discourse-ai/git/trees/#{default_branch}?recursive=1", - ).to_return( - status: 200, - body: { - tree: [ - { path: "lib/modules/ai_bot/tools/github_search_code.rb", type: "blob" }, - { path: "lib/modules/ai_bot/tools/github_file_content.rb", type: "blob" }, - ], - }.to_json, - ) - end - - it "retrieves files matching the specified keywords" do - result = tool.invoke - expected = { - branch: "main", - matching_files: %w[ - lib/modules/ai_bot/tools/github_search_code.rb - lib/modules/ai_bot/tools/github_file_content.rb - ], - } - - expect(result).to eq(expected) - end - - it "handles missing branches gracefully" do - stub_request( - :get, - "https://api.github.com/repos/discourse/discourse-ai/git/trees/non_existing_branch?recursive=1", - ).to_return(status: 404, body: "", headers: { "Content-Type" => "application/json" }) - - tool_with_invalid_branch = - described_class.new( - { - repo: "discourse/discourse-ai", - keywords: %w[search tool], - branch: "non_existing_branch", - }, - bot_user: nil, - llm: llm, - ) - - result = tool_with_invalid_branch.invoke - expect(result[:matching_files]).to be_nil - expect(result[:error]).to eq("Failed to perform file search. Status code: 404") - end - - it "fetches the default branch if none is specified" do - result = tool.invoke - expect(result[:matching_files]).to match_array( - %w[ - lib/modules/ai_bot/tools/github_search_code.rb - lib/modules/ai_bot/tools/github_file_content.rb - ], - ) - expect(result[:error]).to be_nil - end - end -end diff --git a/spec/lib/personas/tools/google_spec.rb b/spec/lib/personas/tools/google_spec.rb deleted file mode 100644 index 512c8842..00000000 --- a/spec/lib/personas/tools/google_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::Google do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:progress_blk) { Proc.new {} } - let(:search) { described_class.new({ query: "some search term" }, bot_user: bot_user, llm: llm) } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - SiteSetting.ai_google_custom_search_api_key = "abc" - SiteSetting.ai_google_custom_search_cx = "cx" - end - - describe "#process" do - it "will not explode if there are no results" do - json_text = { searchInformation: { totalResults: "0" } }.to_json - - stub_request( - :get, - "https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=some%20search%20term", - ).to_return(status: 200, body: json_text, headers: {}) - - info = search.invoke(&progress_blk).to_json - - expect(search.results_count).to eq(0) - expect(info).to_not include("oops") - end - - it "supports base_query" do - base_query = "site:discourse.org" - - search = - described_class.new( - { query: "some search term" }, - bot_user: bot_user, - llm: llm, - persona_options: { - "base_query" => base_query, - }, - ) - - json_text = { searchInformation: { totalResults: "0" } }.to_json - - stub_request( - :get, - "https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=site:discourse.org%20some%20search%20term", - ).to_return(status: 200, body: json_text, headers: {}) - - search.invoke(&progress_blk) - end - - it "can generate correct info" do - json_text = { - searchInformation: { - totalResults: "2", - }, - items: [ - { - title: "title1", - link: "link1", - snippet: "snippet1", - displayLink: "displayLink1", - formattedUrl: "formattedUrl1", - oops: "do no include me ... oops", - }, - { - title: "title2", - link: "link2", - displayLink: "displayLink1", - formattedUrl: "formattedUrl1", - oops: "do no include me ... oops", - }, - ], - }.to_json - - stub_request( - :get, - "https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=some%20search%20term", - ).to_return(status: 200, body: json_text, headers: {}) - - info = search.invoke(&progress_blk).to_json - - expect(search.results_count).to eq(2) - expect(info).to include("title1") - expect(info).to include("snippet1") - expect(info).to include("some+search+term") - expect(info).to include("title2") - expect(info).to_not include("oops") - end - end -end diff --git a/spec/lib/personas/tools/image_spec.rb b/spec/lib/personas/tools/image_spec.rb deleted file mode 100644 index 0e588a8c..00000000 --- a/spec/lib/personas/tools/image_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::Image do - let(:progress_blk) { Proc.new {} } - let(:prompts) { ["a pink cow", "a red cow"] } - - let(:tool) do - described_class.new( - { prompts: prompts, seeds: [99, 32] }, - bot_user: bot_user, - llm: llm, - context: DiscourseAi::Personas::BotContext.new, - ) - end - - fab!(:gpt_35_turbo) { Fabricate(:llm_model, name: "gpt-3.5-turbo") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_35_turbo]) - end - - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{gpt_35_turbo.id}") } - - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(gpt_35_turbo.name) } - - describe "#process" do - it "can generate correct info" do - _post = Fabricate(:post) - - SiteSetting.ai_stability_api_url = "https://api.stability.dev" - SiteSetting.ai_stability_api_key = "abc" - - image = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - - artifacts = [{ base64: image, seed: 99 }] - - WebMock - .stub_request( - :post, - "https://api.stability.dev/v1/generation/#{SiteSetting.ai_stability_engine}/text-to-image", - ) - .with do |request| - json = JSON.parse(request.body, symbolize_names: true) - expect(prompts).to include(json[:text_prompts][0][:text]) - true - end - .to_return(status: 200, body: { artifacts: artifacts }.to_json) - - info = tool.invoke(&progress_blk).to_json - - expect(JSON.parse(info)).to eq("prompts" => ["a pink cow", "a red cow"], "seeds" => [99, 99]) - expect(tool.custom_raw).to include("upload://") - expect(tool.custom_raw).to include("[grid]") - expect(tool.custom_raw).to include("a pink cow") - expect(tool.custom_raw).to include("a red cow") - end - end -end diff --git a/spec/lib/personas/tools/javascript_evaluator_spec.rb b/spec/lib/personas/tools/javascript_evaluator_spec.rb deleted file mode 100644 index caff3b27..00000000 --- a/spec/lib/personas/tools/javascript_evaluator_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::JavascriptEvaluator do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:progress_blk) { Proc.new {} } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#invoke" do - it "successfully evaluates a simple JavaScript expression" do - evaluator = described_class.new({ script: "2 + 2" }, bot_user: bot_user, llm: llm) - - result = evaluator.invoke(&progress_blk) - expect(result[:result]).to eq(4) - end - - it "handles JavaScript execution timeout" do - evaluator = described_class.new({ script: "while(true){}" }, bot_user: bot_user, llm: llm) - - evaluator.timeout = 5 - - result = evaluator.invoke(&progress_blk) - expect(result[:error]).to include("JavaScript execution timed out") - end - - it "handles JavaScript memory limit exceeded" do - evaluator = - described_class.new( - { script: "var a = new Array(10000); while(true) { a = a.concat(new Array(10000)) }" }, - bot_user: bot_user, - llm: llm, - ) - - evaluator.max_memory = 10_000 - result = evaluator.invoke(&progress_blk) - expect(result[:error]).to include("JavaScript execution exceeded memory limit") - end - - it "returns error for invalid JavaScript syntax" do - evaluator = described_class.new({ script: "const x =;" }, bot_user: bot_user, llm: llm) - - result = evaluator.invoke(&progress_blk) - expect(result[:error]).to include("JavaScript execution error: ") - end - - it "truncates long results" do - evaluator = - described_class.new( - { script: "const x = 'zxn'.repeat(10000); x + 'Z';" }, - bot_user: bot_user, - llm: llm, - ) - - result = evaluator.invoke(&progress_blk) - expect(result[:result]).not_to include("Z") - end - - it "returns result for more complex JavaScript" do - evaluator = - described_class.new( - { script: "const x = [1, 2, 3, 4].map(n => n * 2); x.reduce((a, b) => a + b, 0);" }, - bot_user: bot_user, - llm: llm, - ) - - result = evaluator.invoke(&progress_blk) - expect(result[:result]).to eq(20) - end - end -end diff --git a/spec/lib/personas/tools/list_categories_spec.rb b/spec/lib/personas/tools/list_categories_spec.rb deleted file mode 100644 index bc1cac3c..00000000 --- a/spec/lib/personas/tools/list_categories_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::ListCategories do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#process" do - it "list available categories" do - Fabricate(:category, name: "america", posts_year: 999) - - info = described_class.new({}, bot_user: bot_user, llm: llm).invoke.to_s - - expect(info).to include("america") - expect(info).to include("999") - end - end -end diff --git a/spec/lib/personas/tools/list_tags_spec.rb b/spec/lib/personas/tools/list_tags_spec.rb deleted file mode 100644 index 541bd936..00000000 --- a/spec/lib/personas/tools/list_tags_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::ListTags do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - SiteSetting.tagging_enabled = true - end - - describe "#process" do - it "can generate correct info" do - Fabricate(:tag, name: "america", public_topic_count: 100) - Fabricate(:tag, name: "not_here", public_topic_count: 0) - - info = described_class.new({}, bot_user: bot_user, llm: llm).invoke - - expect(info.to_s).to include("america") - expect(info.to_s).not_to include("not_here") - end - end -end diff --git a/spec/lib/personas/tools/random_picker_spec.rb b/spec/lib/personas/tools/random_picker_spec.rb deleted file mode 100644 index bab37c60..00000000 --- a/spec/lib/personas/tools/random_picker_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Personas::Tools::RandomPicker do - before { enable_current_plugin } - - describe "#invoke" do - subject { described_class.new({ options: options }, bot_user: nil, llm: nil).invoke } - - context "with options as simple list of strings" do - let(:options) { %w[apple banana cherry] } - - it "returns one of the options" do - expect(options).to include(subject[:result]) - end - end - - context "with options as ranges" do - let(:options) { %w[1-3 10-20] } - - it "returns a number within one of the provided ranges" do - results = subject[:result] - expect(results).to all( - satisfy { |result| (1..3).include?(result) || (10..20).include?(result) }, - ) - end - end - - context "with options as comma-separated values" do - let(:options) { %w[red,green,blue mon,tue,wed] } - - it "returns one value from each comma-separated list" do - results = subject[:result] - expect(results).to include(a_kind_of(String)) - results.each { |result| expect(result.split(",")).to include(result) } - end - end - - context "with mixed options (list, range, and comma-separated)" do - let(:options) { %w[apple 1-3 mon,tue,wed] } - - it "handles each option appropriately" do - results = subject[:result] - expect(results.size).to eq(options.size) - # Verifying each type of option is respected needs a more elaborate setup, - # potentially mocking or specific expectations for each type. - end - end - - context "with an invalid format in options" do - let(:options) { ["invalid_format"] } - - it "returns an error message for invalid formats" do - expect(subject[:result]).to include("invalid_format") - end - end - end -end diff --git a/spec/lib/personas/tools/read_artifact_spec.rb b/spec/lib/personas/tools/read_artifact_spec.rb deleted file mode 100644 index e1314618..00000000 --- a/spec/lib/personas/tools/read_artifact_spec.rb +++ /dev/null @@ -1,136 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::ReadArtifact do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - fab!(:post) - fab!(:post2) { Fabricate(:post, user: post.user) } - fab!(:artifact) do - AiArtifact.create!( - user: post.user, - post: post, - name: "Test Artifact", - html: "
Test Content
", - css: ".test { color: blue; }", - js: "console.log('test');", - ) - end - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#invoke" do - it "successfully reads a local artifact" do - tool = - described_class.new( - { url: "#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/#{artifact.id}" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") - - new_artifact = AiArtifact.last - expect(new_artifact.html).to eq(artifact.html) - expect(new_artifact.css).to eq(artifact.css) - expect(new_artifact.js).to eq(artifact.js) - expect(new_artifact.metadata["cloned_from"]).to eq(artifact.id) - end - - it "handles invalid URLs" do - tool = - described_class.new( - { url: "invalid-url" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("error") - expect(result[:error]).to eq("Invalid URL") - end - - it "handles non-existent artifacts" do - tool = - described_class.new( - { url: "#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/99999" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("error") - expect(result[:error]).to eq("Artifact not found") - end - - it "handles external web pages" do - stub_request(:get, "https://example.com").to_return(status: 200, body: <<~HTML) - - - - - -
-
External Content
-
- - - - HTML - - stub_request(:get, "https://example.com/style.css").to_return( - status: 200, - body: ".external { color: red; }", - ) - - tool = - described_class.new( - { url: "https://example.com" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") - - new_artifact = AiArtifact.last - expect(new_artifact.html).to include("
External Content
") - expect(new_artifact.css).to include(".external { color: red; }") - expect(new_artifact.js).to include("console.log('test');") - expect(new_artifact.metadata["imported_from"]).to eq("https://example.com") - end - - it "respects MAX_HTML_SIZE limit" do - large_content = "x" * (described_class::MAX_HTML_SIZE + 1000) - - stub_request(:get, "https://example.com").to_return(status: 200, body: <<~HTML) - - -
#{large_content}
- - - HTML - - tool = - described_class.new( - { url: "https://example.com" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") - - new_artifact = AiArtifact.last - expect(new_artifact.html.length).to be <= described_class::MAX_HTML_SIZE - end - end -end diff --git a/spec/lib/personas/tools/read_spec.rb b/spec/lib/personas/tools/read_spec.rb deleted file mode 100644 index d756bee6..00000000 --- a/spec/lib/personas/tools/read_spec.rb +++ /dev/null @@ -1,107 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::Read do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:tool) { described_class.new({ topic_id: topic_with_tags.id }, bot_user: bot_user, llm: llm) } - - fab!(:parent_category) { Fabricate(:category, name: "animals") } - fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") } - - fab!(:tag_funny) { Fabricate(:tag, name: "funny") } - fab!(:tag_sad) { Fabricate(:tag, name: "sad") } - fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") } - fab!(:staff_tag_group) do - tag_group = Fabricate.build(:tag_group, name: "Staff only", tag_names: ["hidden"]) - - tag_group.permissions = [ - [Group::AUTO_GROUPS[:staff], TagGroupPermission.permission_types[:full]], - ] - tag_group.save! - tag_group - end - fab!(:topic_with_tags) do - Fabricate(:topic, category: category, tags: [tag_funny, tag_sad, tag_hidden]) - end - - fab!(:post1) { Fabricate(:post, topic: topic_with_tags, raw: "hello there") } - fab!(:post2) { Fabricate(:post, topic: topic_with_tags, raw: "mister sam") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#process" do - it "can read private topics if allowed to" do - category = topic_with_tags.category - category.set_permissions(Group::AUTO_GROUPS[:staff] => :full) - category.save! - - tool = - described_class.new( - { topic_id: topic_with_tags.id, post_numbers: [post1.post_number] }, - bot_user: bot_user, - llm: llm, - ) - results = tool.invoke - - expect(results[:description]).to eq("Topic not found") - - admin = Fabricate(:admin) - - tool = - described_class.new( - { topic_id: topic_with_tags.id, post_numbers: [post1.post_number] }, - bot_user: bot_user, - llm: llm, - persona_options: { - "read_private" => true, - }, - context: DiscourseAi::Personas::BotContext.new(user: admin), - ) - results = tool.invoke - expect(results[:content]).to include("hello there") - - tool = - described_class.new( - { topic_id: topic_with_tags.id, post_numbers: [post1.post_number] }, - bot_user: bot_user, - llm: llm, - context: DiscourseAi::Personas::BotContext.new(user: admin), - ) - - results = tool.invoke - expect(results[:description]).to eq("Topic not found") - end - - it "can read specific posts" do - tool = - described_class.new( - { topic_id: topic_with_tags.id, post_numbers: [post1.post_number] }, - bot_user: bot_user, - llm: llm, - ) - results = tool.invoke - - expect(results[:content]).to include("hello there") - expect(results[:content]).not_to include("mister sam") - end - it "can read a topic" do - topic_id = topic_with_tags.id - results = tool.invoke - - expect(results[:topic_id]).to eq(topic_id) - expect(results[:content]).to include("hello") - expect(results[:content]).to include("sam") - expect(results[:content]).to include("amazing-cat") - expect(results[:content]).to include("funny") - expect(results[:content]).to include("sad") - expect(results[:content]).to include("animals") - expect(results[:content]).not_to include("hidden") - expect(tool.title).to eq(topic_with_tags.title) - expect(tool.url).to eq(topic_with_tags.relative_url) - end - end -end diff --git a/spec/lib/personas/tools/researcher_spec.rb b/spec/lib/personas/tools/researcher_spec.rb deleted file mode 100644 index d5ea34c6..00000000 --- a/spec/lib/personas/tools/researcher_spec.rb +++ /dev/null @@ -1,187 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::Researcher do - before { SearchIndexer.enable } - after { SearchIndexer.disable } - - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:progress_blk) { Proc.new {} } - - fab!(:admin) - fab!(:user) - fab!(:category) { Fabricate(:category, name: "research-category") } - fab!(:tag_research) { Fabricate(:tag, name: "research") } - fab!(:tag_data) { Fabricate(:tag, name: "data") } - - fab!(:topic_with_tags) { Fabricate(:topic, category: category, tags: [tag_research, tag_data]) } - fab!(:post) { Fabricate(:post, topic: topic_with_tags) } - fab!(:another_post) { Fabricate(:post) } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - it "uses custom researcher_llm and applies token limits correctly" do - # Create a second LLM model to test the researcher_llm option - secondary_llm_model = Fabricate(:llm_model, name: "secondary_model") - - # Create test content with long text to test token truncation - topic = Fabricate(:topic, category: category, tags: [tag_research]) - long_content = "zz " * 100 # This will exceed our token limit - _test_post = - Fabricate(:post, topic: topic, raw: long_content, user: user, skip_validation: true) - - prompts = nil - responses = [["Research completed"]] - researcher = nil - - DiscourseAi::Completions::Llm.with_prepared_responses( - responses, - llm: secondary_llm_model, - ) do |_, _, _prompts| - researcher = - described_class.new( - { filter: "category:research-category", goals: "analyze test content", dry_run: false }, - persona_options: { - "researcher_llm" => secondary_llm_model.id, - "max_tokens_per_post" => 50, # Very small to force truncation - "max_tokens_per_batch" => 8000, - }, - bot_user: bot_user, - llm: nil, - context: DiscourseAi::Personas::BotContext.new(user: user, post: post), - ) - - results = researcher.invoke(&progress_blk) - - expect(results[:dry_run]).to eq(false) - expect(results[:results]).to be_present - - prompts = _prompts - end - - expect(prompts).to be_present - - user_message = prompts.first.messages.find { |m| m[:type] == :user } - expect(user_message[:content]).to be_present - - # count how many times the the "zz " appears in the content (a bit of token magic, we lose a couple cause we redact) - expect(user_message[:content].scan("zz ").count).to eq(48) - end - - describe "#invoke" do - it "can correctly filter to a topic id" do - researcher = - described_class.new( - { dry_run: true, filter: "topic:#{topic_with_tags.id}", goals: "analyze topic content" }, - bot_user: bot_user, - llm: llm, - context: DiscourseAi::Personas::BotContext.new(user: user, post: post), - ) - results = researcher.invoke(&progress_blk) - expect(results[:number_of_posts]).to eq(1) - end - - it "returns filter information and result count" do - researcher = - described_class.new( - { filter: "tag:research after:2023", goals: "analyze post patterns", dry_run: true }, - bot_user: bot_user, - llm: llm, - context: DiscourseAi::Personas::BotContext.new(user: user, post: post), - ) - - results = researcher.invoke(&progress_blk) - - expect(results[:filter]).to eq("tag:research after:2023") - expect(results[:goals]).to eq("analyze post patterns") - expect(results[:dry_run]).to eq(true) - expect(results[:number_of_posts]).to be > 0 - expect(researcher.filter).to eq("tag:research after:2023") - expect(researcher.result_count).to be > 0 - end - - it "handles empty filters" do - researcher = - described_class.new({ goals: "analyze all content" }, bot_user: bot_user, llm: llm) - - results = researcher.invoke(&progress_blk) - - expect(results[:error]).to eq("No filter provided") - end - - it "accepts max_results option" do - researcher = - described_class.new( - { filter: "category:research-category" }, - persona_options: { - "max_results" => "50", - }, - bot_user: bot_user, - llm: llm, - ) - - expect(researcher.options[:max_results]).to eq(50) - end - - it "returns error for invalid filter fragments" do - researcher = - described_class.new( - { filter: "invalidfilter tag:research", goals: "analyze content" }, - bot_user: bot_user, - llm: llm, - context: DiscourseAi::Personas::BotContext.new(user: user, post: post), - ) - - results = researcher.invoke(&progress_blk) - - expect(results[:error]).to include("Invalid filter fragment") - end - - it "returns correct results for non-dry-run with filtered posts" do - # Stage 2 topics, each with 2 posts - topics = Array.new(2) { Fabricate(:topic, category: category, tags: [tag_research]) } - topics.flat_map do |topic| - [ - Fabricate(:post, topic: topic, raw: "Relevant content 1", user: user), - Fabricate(:post, topic: topic, raw: "Relevant content 2", user: admin), - ] - end - - # Filter to posts by user in research-category - researcher = - described_class.new( - { - filter: "category:research-category username:#{user.username}", - goals: "find relevant content", - dry_run: false, - }, - bot_user: bot_user, - llm: llm, - context: DiscourseAi::Personas::BotContext.new(user: user, post: post), - ) - - responses = 10.times.map { |i| ["Found: Relevant content #{i + 1}"] } - results = nil - - last_progress = nil - progress_blk = Proc.new { |response| last_progress = response } - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - researcher.llm = llm_model.to_llm - results = researcher.invoke(&progress_blk) - end - - expect(last_progress).to include("find relevant content") - expect(last_progress).to include("category:research-category") - - expect(results[:dry_run]).to eq(false) - expect(results[:goals]).to eq("find relevant content") - expect(results[:filter]).to eq("category:research-category username:#{user.username}") - expect(results[:results].first).to include("Found: Relevant content 1") - end - end -end diff --git a/spec/lib/personas/tools/search_settings_spec.rb b/spec/lib/personas/tools/search_settings_spec.rb deleted file mode 100644 index f43db552..00000000 --- a/spec/lib/personas/tools/search_settings_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::SearchSettings do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - - let(:fake_settings) do - [ - { setting: "default_locale", description: "The default locale for the site", plugin: "core" }, - { setting: "min_post_length", description: "The minimum length of a post", plugin: "core" }, - { - setting: "ai_bot_enabled", - description: "Enable or disable the AI bot", - plugin: "discourse-ai", - }, - { setting: "min_first_post_length", description: "First post length", plugin: "core" }, - ] - end - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [llm_model]) - end - - def search_settings(query, mock: true) - search = described_class.new({ query: query }, bot_user: bot_user, llm: llm) - search.all_settings = fake_settings if mock - search - end - - describe "#process" do - it "can handle no results" do - results = search_settings("this will not exist frogs").invoke - expect(results[:args]).to eq({ query: "this will not exist frogs" }) - expect(results[:rows]).to eq([]) - end - - it "can find a setting based on fuzzy match" do - results = search_settings("default locale").invoke - expect(results[:rows].length).to eq(1) - expect(results[:rows][0][0]).to eq("default_locale") - - results = search_settings("min_post_length").invoke - - expect(results[:rows].length).to eq(2) - expect(results[:rows][0][0]).to eq("min_post_length") - expect(results[:rows][1][0]).to eq("min_first_post_length") - end - - it "can return more many settings with no descriptions if there are lots of hits" do - results = search_settings("a", mock: false).invoke - - expect(results[:rows].length).to be > 30 - expect(results[:rows][0].length).to eq(1) - end - - it "can return descriptions if there are few matches" do - results = search_settings("this will not be found!@,default_locale,ai_bot_enabled").invoke - - expect(results[:rows].length).to eq(2) - - expect(results[:rows][0][1]).not_to eq(nil) - end - end -end diff --git a/spec/lib/personas/tools/search_spec.rb b/spec/lib/personas/tools/search_spec.rb deleted file mode 100644 index d9ddee46..00000000 --- a/spec/lib/personas/tools/search_spec.rb +++ /dev/null @@ -1,221 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::Search do - before { SearchIndexer.enable } - after { SearchIndexer.disable } - - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:progress_blk) { Proc.new {} } - - fab!(:admin) - fab!(:parent_category) { Fabricate(:category, name: "animals") } - fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") } - fab!(:tag_funny) { Fabricate(:tag, name: "funny") } - fab!(:tag_sad) { Fabricate(:tag, name: "sad") } - fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") } - fab!(:staff_tag_group) do - tag_group = Fabricate.build(:tag_group, name: "Staff only", tag_names: ["hidden"]) - - tag_group.permissions = [ - [Group::AUTO_GROUPS[:staff], TagGroupPermission.permission_types[:full]], - ] - tag_group.save! - tag_group - end - fab!(:topic_with_tags) do - Fabricate(:topic, category: category, tags: [tag_funny, tag_sad, tag_hidden]) - end - - fab!(:user) - fab!(:group) - fab!(:private_category) do - c = Fabricate(:category_with_definition) - c.set_permissions(group => :readonly) - c.save - c - end - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#invoke" do - it "can retrieve options from persona correctly" do - persona_options = { - "base_query" => "#funny", - "search_private" => "true", - "max_results" => "10", - } - - search_post = Fabricate(:post, topic: topic_with_tags) - private_search_post = Fabricate(:post, topic: Fabricate(:topic, category: private_category)) - private_search_post.topic.tags = [tag_funny] - private_search_post.topic.save! - - _bot_post = Fabricate(:post) - - search = - described_class.new( - { order: "latest" }, - persona_options: persona_options, - bot_user: bot_user, - llm: llm, - context: DiscourseAi::Personas::BotContext.new(user: user), - ) - - expect(search.options[:base_query]).to eq("#funny") - expect(search.options[:search_private]).to eq(true) - expect(search.options[:max_results]).to eq(10) - - results = search.invoke(&progress_blk) - expect(results[:rows].length).to eq(1) - - expect(search.last_query).to eq("#funny order:latest") - - GroupUser.create!(group: group, user: user) - - results = search.invoke(&progress_blk) - expect(results[:rows].length).to eq(2) - - search_post.topic.tags = [] - search_post.topic.save! - - # no longer has the tag funny, but secure one does - results = search.invoke(&progress_blk) - expect(results[:rows].length).to eq(1) - end - - it "can handle no results" do - _post1 = Fabricate(:post, topic: topic_with_tags) - search = - described_class.new( - { search_query: "ABDDCDCEDGDG", order: "fake" }, - bot_user: bot_user, - llm: llm, - ) - - results = search.invoke(&progress_blk) - - expect(results[:args]).to eq({ search_query: "ABDDCDCEDGDG", order: "fake", max_results: 60 }) - expect(results[:rows]).to eq([]) - end - - describe "semantic search" do - let(:query) { "this is an expanded search" } - after { DiscourseAi::Embeddings::SemanticSearch.clear_cache_for(query) } - - it "supports semantic search when enabled" do - assign_fake_provider_to(:ai_embeddings_semantic_search_hyde_model) - vector_def = Fabricate(:embedding_definition) - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_semantic_search_enabled = true - - hyde_embedding = [0.049382] * vector_def.dimensions - - EmbeddingsGenerationStubs.hugging_face_service(query, hyde_embedding) - - post1 = Fabricate(:post, topic: topic_with_tags) - search = - described_class.new( - { search_query: "hello world, sam", status: "public" }, - llm: llm, - bot_user: bot_user, - ) - - DiscourseAi::Embeddings::Schema.for(Topic).store(post1.topic, hyde_embedding, "digest") - - results = - DiscourseAi::Completions::Llm.with_prepared_responses([query]) do - search.invoke(&progress_blk) - end - - expect(results[:args]).to eq( - { max_results: 60, search_query: "hello world, sam", status: "public" }, - ) - expect(results[:rows].length).to eq(1) - - # it also works with no query - search = - described_class.new( - { order: "likes", user: "sam", status: "public", search_query: "a" }, - llm: llm, - bot_user: bot_user, - ) - - # results will be expanded by semantic search, but it will find nothing - results = - DiscourseAi::Completions::Llm.with_prepared_responses([query]) do - search.invoke(&progress_blk) - end - - expect(results[:rows].length).to eq(0) - end - end - - it "supports subfolder properly" do - Discourse.stubs(:base_path).returns("/subfolder") - - post1 = Fabricate(:post, topic: topic_with_tags) - - search = - described_class.new({ limit: 1, user: post1.user.username }, bot_user: bot_user, llm: llm) - - results = search.invoke(&progress_blk) - expect(results[:rows].to_s).to include("/subfolder" + post1.url) - end - - it "passes on all search params" do - params = - described_class.signature[:parameters] - .map do |param| - if param[:type] == "integer" - [param[:name], 1] - else - [param[:name], "test"] - end - end - .compact - .to_h - .symbolize_keys - - search = described_class.new(params, bot_user: bot_user, llm: llm) - results = search.invoke(&progress_blk) - - expect(results[:args]).to eq(params) - end - - it "returns rich topic information" do - post1 = Fabricate(:post, like_count: 1, topic: topic_with_tags) - search = described_class.new({ user: post1.user.username }, bot_user: bot_user, llm: llm) - post1.topic.update!(views: 100, posts_count: 2, like_count: 10) - - results = search.invoke(&progress_blk) - - row = results[:rows].first - category = row[results[:column_names].index("category")] - - expect(category).to eq("animals > amazing-cat") - - tags = row[results[:column_names].index("tags")] - expect(tags).to eq("funny, sad") - - likes = row[results[:column_names].index("likes")] - expect(likes).to eq(1) - - username = row[results[:column_names].index("username")] - expect(username).to eq(post1.user.username) - - likes = row[results[:column_names].index("topic_likes")] - expect(likes).to eq(10) - - views = row[results[:column_names].index("topic_views")] - expect(views).to eq(100) - - replies = row[results[:column_names].index("topic_replies")] - expect(replies).to eq(1) - end - end -end diff --git a/spec/lib/personas/tools/setting_context_spec.rb b/spec/lib/personas/tools/setting_context_spec.rb deleted file mode 100644 index c5e87513..00000000 --- a/spec/lib/personas/tools/setting_context_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -def has_rg? - if defined?(@has_rg) - @has_rg - else - @has_rg |= system("which rg") - end -end - -RSpec.describe DiscourseAi::Personas::Tools::SettingContext, if: has_rg? do - fab!(:llm_model) - - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - def setting_context(setting_name) - described_class.new({ setting_name: setting_name }, bot_user: bot_user, llm: llm) - end - - describe "#execute" do - it "returns the context for core setting" do - result = setting_context("moderators_view_emails").invoke - - expect(result[:setting_name]).to eq("moderators_view_emails") - - expect(result[:context]).to include("site_settings.yml") - expect(result[:context]).to include("moderators_view_emails") - end - - it "supports spaces and case insensitive setting name" do - result = setting_context("moderaTors View Emails").invoke - - expect(result[:setting_name]).to eq("moderators_view_emails") - - expect(result[:context]).to include("site_settings.yml") - expect(result[:context]).to include("moderators_view_emails") - end - - it "returns the context for plugin setting" do - result = setting_context("ai_bot_enabled").invoke - - expect(result[:setting_name]).to eq("ai_bot_enabled") - expect(result[:context]).to include("ai_bot_enabled:") - end - - context "when the setting does not exist" do - it "returns an error message" do - result = setting_context("this_setting_does_not_exist").invoke - - expect(result[:context]).to eq("This setting does not exist") - end - end - end -end diff --git a/spec/lib/personas/tools/summarize_spec.rb b/spec/lib/personas/tools/summarize_spec.rb deleted file mode 100644 index de5ce194..00000000 --- a/spec/lib/personas/tools/summarize_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::Summarize do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - let(:progress_blk) { Proc.new {} } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - let(:summary) { "summary stuff" } - - describe "#process" do - it "can generate correct info" do - post = Fabricate(:post) - - DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do - summarization = - described_class.new( - { topic_id: post.topic_id, guidance: "why did it happen?" }, - bot_user: bot_user, - llm: llm, - ) - info = summarization.invoke(&progress_blk) - - expect(info).to include("Topic summarized") - expect(summarization.custom_raw).to include(summary) - expect(summarization.chain_next_response?).to eq(false) - end - end - - it "protects hidden data" do - category = Fabricate(:category) - category.set_permissions({}) - category.save! - - topic = Fabricate(:topic, category_id: category.id) - post = Fabricate(:post, topic: topic) - - DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do - summarization = - described_class.new( - { topic_id: post.topic_id, guidance: "why did it happen?" }, - bot_user: bot_user, - llm: llm, - ) - info = summarization.invoke(&progress_blk) - - expect(info).not_to include(post.raw) - - expect(summarization.custom_raw).to eq(I18n.t("discourse_ai.ai_bot.topic_not_found")) - end - end - end -end diff --git a/spec/lib/personas/tools/time_spec.rb b/spec/lib/personas/tools/time_spec.rb deleted file mode 100644 index 3ddd126d..00000000 --- a/spec/lib/personas/tools/time_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -#frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::Time do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#process" do - it "can generate correct info" do - freeze_time - - args = { timezone: "America/Los_Angeles" } - info = described_class.new(args, bot_user: bot_user, llm: llm).invoke - - expect(info).to eq({ args: args, time: Time.now.in_time_zone("America/Los_Angeles").to_s }) - expect(info.to_s).not_to include("not_here") - end - end -end diff --git a/spec/lib/personas/tools/tool_spec.rb b/spec/lib/personas/tools/tool_spec.rb deleted file mode 100644 index fb8f41d1..00000000 --- a/spec/lib/personas/tools/tool_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::Tool do - let :tool_class do - described_class - end - - let :corrupt_string do - "\xC3\x28\xA0\xA1\xE2\x28\xA1\xE2\x82\x28\xF0\x28\x8C\xBC" - end - - before { enable_current_plugin } - - describe "#read_response_body" do - class FakeResponse - def initialize(chunk) - @chunk = chunk - end - - def read_body - yield @chunk while true - end - end - - it "never returns a corrupt string" do - response = FakeResponse.new(corrupt_string) - result = tool_class.read_response_body(response, max_length: 100.bytes) - - expect(result.encoding).to eq(Encoding::UTF_8) - expect(result.valid_encoding?).to eq(true) - - # scrubbing removes 7 chars - expect(result.length).to eq(93) - end - - it "returns correctly truncated strings" do - response = FakeResponse.new("abc") - result = tool_class.read_response_body(response, max_length: 10.bytes) - - expect(result.encoding).to eq(Encoding::UTF_8) - expect(result.valid_encoding?).to eq(true) - - expect(result).to eq("abcabcabca") - end - end -end diff --git a/spec/lib/personas/tools/update_artifact_spec.rb b/spec/lib/personas/tools/update_artifact_spec.rb deleted file mode 100644 index efd7fa80..00000000 --- a/spec/lib/personas/tools/update_artifact_spec.rb +++ /dev/null @@ -1,362 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Personas::Tools::UpdateArtifact do - fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - fab!(:post) - fab!(:artifact) do - AiArtifact.create!( - user: Fabricate(:user), - post: post, - name: "Test Artifact", - html: "
Original
", - css: ".test { color: blue; }", - js: "console.log('original');\nconsole.log('world');\nconsole.log('hello');", - ) - end - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#process" do - it "correctly updates artifact using section markers" do - responses = [<<~TXT.strip] - [HTML] -
Updated
- [/HTML] - [CSS] - .test { color: red; } - [/CSS] - [JavaScript] - console.log('updated'); - console.log('world'); - console.log('updated2'); - [/JavaScript] - TXT - - tool = nil - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - tool = - described_class.new( - { - artifact_id: artifact.id, - instructions: "Change the text to Updated and color to red", - }, - bot_user: bot_user, - llm: llm_model.to_llm, - persona_options: { - "update_algorithm" => "full", - }, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") - end - - version = artifact.versions.order(:version_number).last - expect(version.html).to eq("
Updated
") - expect(version.css).to eq(".test { color: red; }") - expect(version.js).to eq(<<~JS.strip) - console.log('updated'); - console.log('world'); - console.log('updated2'); - JS - - expect(tool.custom_raw).to include("Change Description") - expect(tool.custom_raw).to include("[details='View Changes']") - expect(tool.custom_raw).to include("### HTML Changes") - expect(tool.custom_raw).to include("### CSS Changes") - expect(tool.custom_raw).to include("### JS Changes") - expect(tool.custom_raw).to include("
"full", - }, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") - end - - version = artifact.versions.order(:version_number).last - expect(version.html).to eq("
Original
") - expect(version.css).to eq(".test { color: blue; }") - expect(version.js).to eq( - "console.log('updated');\nconsole.log('world');\nconsole.log('hello');", - ) - end - - it "handles invalid section format" do - responses = ["Invalid format without proper section markers"] - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - tool = - described_class.new( - { artifact_id: artifact.id, instructions: "Invalid update" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") # The strategy will just keep original content - end - end - - it "handles invalid artifact ID" do - tool = - described_class.new( - { artifact_id: -1, instructions: "Update something" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("error") - expect(result[:error]).to eq("Artifact not found") - end - - it "preserves unchanged sections in the diff output" do - responses = [<<~TXT.strip] - [HTML] -
Updated
- [/HTML] - TXT - - tool = nil - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - tool = - described_class.new( - { artifact_id: artifact.id, instructions: "Just update the HTML" }, - bot_user: bot_user, - llm: llm_model.to_llm, - persona_options: { - "update_algorithm" => "full", - }, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - ) - - tool.invoke {} - end - - version = artifact.versions.order(:version_number).last - expect(version.css).to eq(artifact.css) - expect(version.js).to eq(artifact.js) - expect(tool.custom_raw).to include("### HTML Changes") - expect(tool.custom_raw).not_to include("### CSS Changes") - expect(tool.custom_raw).not_to include("### JavaScript Changes") - end - - it "handles updates to specific versions" do - # Create first version - responses = [<<~TXT.strip] - [HTML] -
Version 1
- [/HTML] - TXT - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - described_class - .new( - { artifact_id: artifact.id, instructions: "Update to version 1" }, - bot_user: bot_user, - llm: llm_model.to_llm, - persona_options: { - "update_algorithm" => "full", - }, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - ) - .invoke {} - end - - first_version = artifact.versions.order(:version_number).last - - responses = [<<~TXT.strip] - [HTML] -
Updated from version 1
- [/HTML] - TXT - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - tool = - described_class.new( - { - artifact_id: artifact.id, - version: first_version.version_number, - instructions: "Update from version 1", - }, - bot_user: bot_user, - llm: llm_model.to_llm, - persona_options: { - "update_algorithm" => "full", - }, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") - end - - latest_version = artifact.versions.order(:version_number).last - expect(latest_version.html).to eq("
Updated from version 1
") - end - end - - it "correctly updates artifact using diff strategy (partial diff)" do - responses = [<<~TXT.strip] - - [HTML] - nonsense - <<<<<<< SEARCH -
Original
- ======= -
Updated
- >>>>>>> REPLACE - garbage llm injects - [/HTML] - - [CSS] - garbage llm injects - <<<<<<< SEARCH - .test { color: blue; } - ======= - .test { color: red; } - >>>>>>> REPLACE - nonsense - [/CSS] - - [JavaScript] - no changes - [/JavaScript] - - LLMs like to say nonsense that we can ignore here as well - TXT - - tool = nil - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - tool = - described_class.new( - { artifact_id: artifact.id, instructions: "Change the text to Updated and color to red" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - persona_options: { - "update_algorithm" => "diff", - }, - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") - end - - version = artifact.versions.order(:version_number).last - expect(version.html).to eq("
Updated
") - expect(version.css).to eq(".test { color: red; }") - expect(version.js).to eq(<<~JS.strip) - console.log('original'); - console.log('world'); - console.log('hello'); - JS - - expect(tool.custom_raw).to include("Change Description") - expect(tool.custom_raw).to include("[details='View Changes']") - expect(tool.custom_raw).to include("### HTML Changes") - expect(tool.custom_raw).to include("### CSS Changes") - expect(tool.custom_raw).to include("
Original
- ======= -
Updated
- >>>>>>> REPLACE - [/HTML] - - [CSS] - <<<<<<< SEARCH - .test { color: blue; } - ======= - .test { color: red; } - >>>>>>> REPLACE - [/CSS] - - [JavaScript] - <<<<<<< SEARCH - console.log('original'); - console.log('world'); - console.log('hello'); - ======= - console.log('updated'); - console.log('world'); - console.log('updated sam'); - >>>>>>> REPLACE - [/JavaScript] - - LLMs like to say nonsense that we can ignore here - TXT - - tool = nil - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - tool = - described_class.new( - { artifact_id: artifact.id, instructions: "Change the text to Updated and color to red" }, - bot_user: bot_user, - llm: llm_model.to_llm, - context: DiscourseAi::Personas::BotContext.new(messages: [], post: post), - persona_options: { - "update_algorithm" => "diff", - }, - ) - - result = tool.invoke {} - expect(result[:status]).to eq("success") - end - - version = artifact.versions.order(:version_number).last - expect(version.html).to eq("
Updated
") - expect(version.css).to eq(".test { color: red; }") - expect(version.js).to eq(<<~JS.strip) - console.log('updated'); - console.log('world'); - console.log('updated sam'); - JS - - expect(tool.custom_raw).to include("Change Description") - expect(tool.custom_raw).to include("[details='View Changes']") - expect(tool.custom_raw).to include("### HTML Changes") - expect(tool.custom_raw).to include("### CSS Changes") - expect(tool.custom_raw).to include("### JS Changes") - expect(tool.custom_raw).to include("
Test

This is a simplified version of the webpage content.

", - ) - - tool = described_class.new({ url: url }, bot_user: bot_user, llm: llm) - result = tool.invoke - - expect(result).to have_key(:text) - expect(result[:text]).to eq(processed_text) - expect(result[:url]).to eq(url) - end - - it "returns an error if the webpage cannot be retrieved" do - url = "https://arxiv.org/html/2403.17011v1" - - # Simulating a failed request - stub_request(:get, url).to_return(status: [500, "Internal Server Error"]) - - tool = described_class.new({ url: url }, bot_user: bot_user, llm: llm) - result = tool.invoke - - expect(result).to have_key(:error) - expect(result[:error]).to include("Failed to retrieve the web page") - end - end - - describe "#invoke with various HTML structures" do - let(:url) { "http://example.com" } - - it "extracts main content from a simple HTML structure" do - simple_html = "

Simple content.

" - stub_request(:get, url).to_return(status: 200, body: simple_html) - - tool = described_class.new({ url: url }, bot_user: bot_user, llm: llm) - result = tool.invoke - - expect(result[:text]).to eq("Simple content.") - end - - it "correctly ignores script and style tags" do - complex_html = - "

Only relevant content here.

" - stub_request(:get, url).to_return(status: 200, body: complex_html) - - tool = described_class.new({ url: url }, bot_user: bot_user, llm: llm) - result = tool.invoke - - expect(result[:text]).to eq("Only relevant content here.") - end - - it "extracts content from nested structures" do - nested_html = - "

Nested paragraph 1.

Nested paragraph 2.

" - stub_request(:get, url).to_return(status: 200, body: nested_html) - - tool = described_class.new({ url: url }, bot_user: bot_user, llm: llm) - result = tool.invoke - - expect(result[:text]).to eq("Nested paragraph 1. Nested paragraph 2.") - end - end - - describe "#invoke with redirects" do - let(:initial_url) { "http://initial-example.com" } - let(:final_url) { "http://final-example.com" } - let(:redirect_html) { "

Redirected content.

" } - - it "follows redirects and retrieves content from the final destination" do - stub_request(:get, initial_url).to_return(status: 302, headers: { "Location" => final_url }) - stub_request(:get, final_url).to_return(status: 200, body: redirect_html) - - tool = described_class.new({ url: initial_url }, bot_user: bot_user, llm: llm) - result = tool.invoke - - expect(result[:url]).to eq(final_url) - expect(result[:text]).to eq("Redirected content.") - end - end -end diff --git a/spec/lib/translation/base_translator_spec.rb b/spec/lib/translation/base_translator_spec.rb deleted file mode 100644 index a9b0541b..00000000 --- a/spec/lib/translation/base_translator_spec.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::BaseTranslator do - let!(:persona) do - AiPersona.find( - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::PostRawTranslator], - ) - end - - before do - enable_current_plugin - - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - - SiteSetting.ai_translation_enabled = true - end - - describe ".translate" do - let(:text) { "cats are great" } - let(:target_locale) { "de" } - let(:llm_response) { "hur dur hur dur!" } - fab!(:post) - fab!(:topic) { post.topic } - - it "creates the correct prompt" do - post_translator = - DiscourseAi::Translation::PostRawTranslator.new(text:, target_locale:, post:) - allow(DiscourseAi::Completions::Prompt).to receive(:new).with( - persona.system_prompt, - messages: array_including({ type: :user, content: a_string_including(text) }), - post_id: post.id, - topic_id: post.topic_id, - ).and_call_original - - DiscourseAi::Completions::Llm.with_prepared_responses([llm_response]) do - post_translator.translate - end - end - - it "creates BotContext with the correct parameters and calls bot.reply with correct args" do - post_translator = - DiscourseAi::Translation::PostRawTranslator.new(text:, target_locale:, post:, topic:) - - expected_content = { content: text, target_locale: target_locale }.to_json - - bot_context = instance_double(DiscourseAi::Personas::BotContext) - allow(DiscourseAi::Personas::BotContext).to receive(:new).and_return(bot_context) - - mock_bot = instance_double(DiscourseAi::Personas::Bot) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(mock_bot) - allow(mock_bot).to receive(:reply).and_yield(llm_response) - - post_translator.translate - - expect(DiscourseAi::Personas::BotContext).to have_received(:new).with( - user: an_instance_of(User), - skip_tool_details: true, - feature_name: "translation", - messages: [{ type: :user, content: expected_content }], - topic: topic, - post: post, - ) - - expect(DiscourseAi::Personas::Bot).to have_received(:as) - expect(mock_bot).to have_received(:reply).with(bot_context, llm_args: { max_tokens: 500 }) - end - - it "sets max_tokens correctly based on text length" do - test_cases = [ - ["Short text", 500], # Short text (< 100 chars) - ["a" * 200, 1000], # Medium text (100-500 chars) - ["a" * 600, 1200], # Long text (> 500 chars, 600*2=1200) - ] - - test_cases.each do |text, expected_max_tokens| - translator = DiscourseAi::Translation::PostRawTranslator.new(text: text, target_locale:) - - bot_context = instance_double(DiscourseAi::Personas::BotContext) - allow(DiscourseAi::Personas::BotContext).to receive(:new).and_return(bot_context) - - mock_bot = instance_double(DiscourseAi::Personas::Bot) - allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(mock_bot) - allow(mock_bot).to receive(:reply).and_yield("translated #{text[0..10]}") - - translator.translate - - expect(mock_bot).to have_received(:reply).with( - bot_context, - llm_args: { - max_tokens: expected_max_tokens, - }, - ) - end - end - - it "returns the translation from the llm's response" do - DiscourseAi::Completions::Llm.with_prepared_responses([llm_response]) do - expect( - DiscourseAi::Translation::PostRawTranslator.new(text:, target_locale:).translate, - ).to eq "hur dur hur dur!" - end - end - end -end diff --git a/spec/lib/translation/category_locale_detector_spec.rb b/spec/lib/translation/category_locale_detector_spec.rb deleted file mode 100644 index 3883552f..00000000 --- a/spec/lib/translation/category_locale_detector_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::CategoryLocaleDetector do - before { enable_current_plugin } - - describe ".detect_locale" do - fab!(:category) do - Fabricate( - :category, - name: "Hello world", - description: "Welcome to this category", - locale: nil, - ) - end - - def language_detector_stub(opts) - mock = instance_double(DiscourseAi::Translation::LanguageDetector) - allow(DiscourseAi::Translation::LanguageDetector).to receive(:new).with( - opts[:text], - ).and_return(mock) - allow(mock).to receive(:detect).and_return(opts[:locale]) - end - - it "returns nil if category is blank" do - expect(described_class.detect_locale(nil)).to eq(nil) - end - - it "updates the category locale with the detected locale" do - text = "#{category.name}\n\n#{category.description}" - language_detector_stub({ text: text, locale: "zh_CN" }) - - expect { described_class.detect_locale(category) }.to change { category.reload.locale }.from( - nil, - ).to("zh_CN") - end - - it "handles category with no description" do - no_description_category = - Fabricate(:category, name: "Test Category", description: nil, locale: nil) - language_detector_stub({ text: no_description_category.name, locale: "fr" }) - - expect { described_class.detect_locale(no_description_category) }.to change { - no_description_category.reload.locale - }.from(nil).to("fr") - end - - it "bypasses validations when updating locale" do - language_detector_stub( - { text: "#{category.name}\n\n#{category.description}", locale: "zh_CN" }, - ) - - described_class.detect_locale(category) - expect(category.reload.locale).to eq("zh_CN") - end - end -end diff --git a/spec/lib/translation/category_localizer_spec.rb b/spec/lib/translation/category_localizer_spec.rb deleted file mode 100644 index 05289680..00000000 --- a/spec/lib/translation/category_localizer_spec.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::CategoryLocalizer do - subject(:localizer) { described_class } - - before do - enable_current_plugin - - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - end - - def post_raw_translator_stub(opts) - mock = instance_double(DiscourseAi::Translation::PostRawTranslator) - allow(DiscourseAi::Translation::PostRawTranslator).to receive(:new).with( - text: opts[:text], - target_locale: opts[:target_locale], - ).and_return(mock) - allow(mock).to receive(:translate).and_return(opts[:translated]) - end - - def short_text_translator_stub(opts) - mock = instance_double(DiscourseAi::Translation::ShortTextTranslator) - allow(DiscourseAi::Translation::ShortTextTranslator).to receive(:new).with( - text: opts[:text], - target_locale: opts[:target_locale], - ).and_return(mock) - allow(mock).to receive(:translate).and_return(opts[:translated]) - end - - fab!(:category) do - Fabricate(:category, name: "Test Category", description: "This is a test category. " * 50) - end - - describe ".localize" do - let(:target_locale) { "fr" } - - it "translates the category name and description" do - translated_cat_desc = "C'est une catégorie de test" - translated_cat_name = "Catégorie de Test" - short_text_translator_stub( - { text: category.name, target_locale: target_locale, translated: translated_cat_name }, - ) - post_raw_translator_stub( - { - text: category.description_excerpt, - target_locale: target_locale, - translated: translated_cat_desc, - }, - ) - - res = localizer.localize(category, target_locale) - - expect(res.name).to eq(translated_cat_name) - expect(res.description).to eq(translated_cat_desc) - end - - it "handles locale format standardization" do - translated_cat_desc = "C'est une catégorie de test" - translated_cat_name = "Catégorie de Test" - short_text_translator_stub( - { text: category.name, target_locale:, translated: translated_cat_name }, - ) - post_raw_translator_stub( - { text: category.description_excerpt, target_locale:, translated: translated_cat_desc }, - ) - - res = localizer.localize(category, target_locale) - - expect(res.name).to eq(translated_cat_name) - expect(res.description).to eq(translated_cat_desc) - end - - it "returns nil if category is blank" do - expect(localizer.localize(nil)).to be_nil - end - - it "returns nil if target locale is blank" do - expect(localizer.localize(category, nil)).to be_nil - end - - it "uses I18n.locale as default when no target locale is provided" do - I18n.locale = :es - translated_cat_desc = "C'est une catégorie de test" - translated_cat_name = "Esta es una categoría de prueba" - short_text_translator_stub( - { text: category.name, target_locale: "es", translated: translated_cat_name }, - ) - post_raw_translator_stub( - { - text: category.description_excerpt, - target_locale: "es", - translated: translated_cat_desc, - }, - ) - - res = localizer.localize(category) - - expect(res.name).to eq(translated_cat_name) - expect(res.description).to eq(translated_cat_desc) - expect(res.locale).to eq("es") - end - end -end diff --git a/spec/lib/translation/content_splitter_spec.rb b/spec/lib/translation/content_splitter_spec.rb deleted file mode 100644 index db4d9564..00000000 --- a/spec/lib/translation/content_splitter_spec.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::ContentSplitter do - before { enable_current_plugin } - - it "returns empty array for empty input" do - expect(described_class.split(content: "")).to eq([""]) - end - - it "handles content with only spaces" do - expect(described_class.split(content: " ")).to eq([" "]) - expect(described_class.split(content: " ")).to eq([" "]) - end - - it "handles nil input" do - expect(described_class.split(content: nil)).to eq([]) - end - - it "doesn't split content under limit" do - content = "hello world" - expect(described_class.split(content:, chunk_size: 20)).to eq([content]) - end - - it "splits to max chunk size if unsplittable" do - content = "a" * 100 - expect(described_class.split(content:, chunk_size: 10)).to eq(["a" * 10] * 10) - end - - it "preserves HTML tags" do - content = "

hello

meow

" - expect(described_class.split(content:, chunk_size: 15)).to eq(%w[

hello

meow

]) - - content = "
hello
jurassic

world

" - expect(described_class.split(content:, chunk_size: 40)).to eq( - ["
hello
jurassic
", "

world

"], - ) - end - - it "preserves BBCode tags" do - content = "[quote]hello[/quote][details]world[/details]" - expect(described_class.split(content:, chunk_size: 25)).to eq( - ["[quote]hello[/quote]", "[details]world[/details]"], - ) - end - - it "doesn't split in middle of words" do - content = "my kitty best in the world" - expect(described_class.split(content:, chunk_size: 10)).to eq( - ["my kitty ", "best in ", "the world"], - ) - end - - it "handles nested tags properly" do - content = "
hello

cat

world

meow

" - expect(described_class.split(content:, chunk_size: 35)).to eq( - %w[
hello

cat

world

meow

], - ) - end - - it "handles mixed HTML and BBCode" do - content = "
hello
[quote]world[/quote]

beautiful

" - expect(described_class.split(content:, chunk_size: 20)).to eq( - ["
hello
", "[quote]world[/quote]", "

beautiful

"], - ) - end - - it "preserves newlines in sensible places" do - content = "hello\nbeautiful\nworld\n" - expect(described_class.split(content:, chunk_size: 10)).to eq( - ["hello\n", "beautiful\n", "world\n"], - ) - end - - it "handles email content properly" do - content = "From: test@test.com\nTo: other@test.com\nSubject: Hello\n\nContent here" - expect(described_class.split(content:, chunk_size: 20)).to eq( - ["From: test@test.com\n", "To: other@test.com\n", "Subject: Hello\n\n", "Content here"], - ) - end - - it "keeps code blocks intact" do - content = "Text\n```\ncode block\nhere\n```\nmore text" - expect(described_class.split(content:, chunk_size: 30)).to eq( - ["Text\n```\ncode block\nhere\n```\n", "more text"], - ) - end - - context "with multiple details tags" do - it "splits correctly between details tags" do - content = "
first content
second content
" - expect(described_class.split(content:, chunk_size: 35)).to eq( - ["
first content
", "
second content
"], - ) - end - end -end diff --git a/spec/lib/translation/entry_point_spec.rb b/spec/lib/translation/entry_point_spec.rb deleted file mode 100644 index 9459c400..00000000 --- a/spec/lib/translation/entry_point_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::EntryPoint do - before do - enable_current_plugin - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - SiteSetting.ai_translation_enabled = true - SiteSetting.content_localization_supported_locales = "en" - end - - describe "upon post process cooked" do - it "enqueues detect post locale and translate post job" do - post = - PostCreator.create!(Fabricate(:user), raw: "post", title: "topic", skip_validations: true) - expect_job_enqueued(job: :detect_translate_post, args: { post_id: post.id }) - end - - it "does not enqueue if setting disabled" do - SiteSetting.ai_translation_enabled = false - post = Fabricate(:post) - CookedPostProcessor.new(post).post_process - - expect(job_enqueued?(job: :detect_translate_post, args: { post_id: post.id })).to eq false - end - end - - describe "upon topic created" do - it "enqueues detect topic locale and translate topic job" do - topic = - PostCreator.create!( - Fabricate(:admin), - raw: "post", - title: "topic", - skip_validations: true, - ).topic - - expect_job_enqueued(job: :detect_translate_topic, args: { topic_id: topic.id }) - end - - it "does not enqueue if setting disabled" do - SiteSetting.ai_translation_enabled = false - topic = - PostCreator.create!( - Fabricate(:admin), - raw: "post", - title: "topic", - skip_validations: true, - ).topic - - expect(job_enqueued?(job: :detect_translate_topic, args: { topic_id: topic.id })).to eq false - end - end - - describe "upon first post (topic) edited" do - fab!(:post) { Fabricate(:post, post_number: 1) } - fab!(:non_first_post) { Fabricate(:post, post_number: 2) } - - before do - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - end - - it "enqueues detect topic locale and translate topic job" do - SiteSetting.ai_translation_enabled = true - topic = post.topic - revisor = PostRevisor.new(post, topic) - revisor.revise!( - post.user, - { title: "A whole new hole" }, - { validate_post: false, bypass_bump: false }, - ) - revisor.post_process_post - - expect_job_enqueued(job: :detect_translate_topic, args: { topic_id: topic.id }) - end - - it "does not enqueue if setting disabled" do - SiteSetting.ai_translation_enabled = false - - expect( - job_enqueued?(job: :detect_translate_topic, args: { topic_id: post.topic_id }), - ).to eq false - end - end -end diff --git a/spec/lib/translation/language_detector_spec.rb b/spec/lib/translation/language_detector_spec.rb deleted file mode 100644 index d52f12f3..00000000 --- a/spec/lib/translation/language_detector_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::LanguageDetector do - let!(:persona) do - AiPersona.find( - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::LocaleDetector], - ) - end - - before do - enable_current_plugin - - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("ai_translation_model=", "custom:#{fake_llm.id}") - end - end - - describe ".detect" do - let(:locale_detector) { described_class.new("meow") } - let(:llm_response) { "hur dur hur dur!" } - - it "creates the correct prompt" do - allow(DiscourseAi::Completions::Prompt).to receive(:new).with( - persona.system_prompt, - messages: [{ type: :user, content: "meow", id: "user" }], - ).and_call_original - - DiscourseAi::Completions::Llm.with_prepared_responses([llm_response]) do - locale_detector.detect - end - end - - it "sends the language detection prompt to the ai helper model" do - mock_prompt = instance_double(DiscourseAi::Completions::Prompt) - mock_llm = instance_double(DiscourseAi::Completions::Llm) - - structured_output = - DiscourseAi::Completions::StructuredOutput.new({ locale: { type: "string" } }) - structured_output << { locale: llm_response }.to_json - - allow(DiscourseAi::Completions::Prompt).to receive(:new).and_return(mock_prompt) - allow(DiscourseAi::Completions::Llm).to receive(:proxy).with( - SiteSetting.ai_translation_model, - ).and_return(mock_llm) - allow(mock_llm).to receive(:generate).with( - mock_prompt, - user: Discourse.system_user, - feature_name: "translation", - response_format: persona.response_format, - ).and_return(structured_output) - - locale_detector.detect - end - - it "returns the language from the llm's response in the language tag" do - DiscourseAi::Completions::Llm.with_prepared_responses([llm_response]) do - locale_detector.detect - end - end - end -end diff --git a/spec/lib/translation/post_detection_text_spec.rb b/spec/lib/translation/post_detection_text_spec.rb deleted file mode 100644 index ec3c30a4..00000000 --- a/spec/lib/translation/post_detection_text_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::PostDetectionText do - before { enable_current_plugin } - - describe ".get_text" do - let(:post) { Fabricate.build(:post) } - - it "returns nil when post is nil" do - expect(described_class.get_text(nil)).to be_nil - end - - it "returns nil when post.cooked is nil" do - post.cooked = nil - expect(described_class.get_text(post)).to be_nil - end - - it "handles simple text" do - post.cooked = "

Hello world

" - expect(described_class.get_text(post)).to eq("Hello world") - end - - it "removes quotes" do - post.cooked = "

Hello

Quote

World

" - expect(described_class.get_text(post)).to eq("Hello World") - end - - it "removes Discourse quotes" do - post.cooked = '

Hello

World

' - expect(described_class.get_text(post)).to eq("Hello World") - end - - it "removes image captions" do - post.cooked = '

Hello

World

' - expect(described_class.get_text(post)).to eq("Hello World") - end - - it "removes oneboxes" do - post.cooked = '

Hello

World

' - expect(described_class.get_text(post)).to eq("Hello World") - end - - it "removes code blocks" do - post.cooked = "

Hello

Code block

World

" - expect(described_class.get_text(post)).to eq("Hello World") - end - - it "removes hashtags" do - post.cooked = '

Hello

#hashtag

World

' - expect(described_class.get_text(post)).to eq("Hello World") - end - - it "removes emoji" do - post.cooked = '

Hello

:smile:

World

' - expect(described_class.get_text(post)).to eq("Hello World") - end - - it "removes mentions" do - post.cooked = '

Hello

@user

World

' - expect(described_class.get_text(post)).to eq("Hello World") - end - - it "falls back to necessary text when preferred is empty" do - post.cooked = '@user' - expect(described_class.get_text(post)).to eq("@user") - end - - it "falls back to cooked when all filtering removes all content" do - post.cooked = "
Quote
" - expect(described_class.get_text(post)).to eq("Quote") - end - - it "handles complex nested content correctly" do - post.cooked = - '

Hello

Quote text

World

Code block
@user' - expect(described_class.get_text(post)).to eq("Hello World") - end - end -end diff --git a/spec/lib/translation/post_locale_detector_spec.rb b/spec/lib/translation/post_locale_detector_spec.rb deleted file mode 100644 index cec8cf57..00000000 --- a/spec/lib/translation/post_locale_detector_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::PostLocaleDetector do - before { enable_current_plugin } - describe ".detect_locale" do - fab!(:post) { Fabricate(:post, cooked: "Hello world", locale: nil) } - - def language_detector_stub(opts) - mock = instance_double(DiscourseAi::Translation::LanguageDetector) - allow(DiscourseAi::Translation::LanguageDetector).to receive(:new).with( - opts[:text], - post: opts[:post], - ).and_return(mock) - allow(mock).to receive(:detect).and_return(opts[:locale]) - end - - it "returns nil if post is blank" do - expect(described_class.detect_locale(nil)).to eq(nil) - end - - it "updates the post locale with the detected locale" do - language_detector_stub({ text: post.cooked, locale: "zh_CN", post: }) - expect { described_class.detect_locale(post) }.to change { post.reload.locale }.from(nil).to( - "zh_CN", - ) - end - - it "bypasses validations when updating locale" do - post.update_column(:cooked, "A") - - language_detector_stub({ text: post.cooked, locale: "zh_CN", post: }) - - described_class.detect_locale(post) - expect(post.reload.locale).to eq("zh_CN") - end - end -end diff --git a/spec/lib/translation/post_localizer_spec.rb b/spec/lib/translation/post_localizer_spec.rb deleted file mode 100644 index 78774481..00000000 --- a/spec/lib/translation/post_localizer_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::PostLocalizer do - before { enable_current_plugin } - - describe ".localize" do - fab!(:post) { Fabricate(:post, raw: "Hello world", version: 1) } - let(:translator) { mock } - let(:translated_raw) { "こんにちは世界" } - let(:cooked) { "

こんにちは世界

" } - let(:target_locale) { "ja" } - - def post_raw_translator_stub(opts) - mock = instance_double(DiscourseAi::Translation::PostRawTranslator) - allow(DiscourseAi::Translation::PostRawTranslator).to receive(:new).with( - text: opts[:text], - target_locale: opts[:target_locale], - post: opts[:post] || post, - ).and_return(mock) - allow(mock).to receive(:translate).and_return(opts[:translated]) - end - - it "returns nil if post does not exist" do - expect(described_class.localize(nil, "ja")).to eq(nil) - end - - it "returns nil if target_locale is blank" do - expect(described_class.localize(post, nil)).to eq(nil) - expect(described_class.localize(post, "")).to eq(nil) - end - - it "returns nil if target_locale is same as post locale" do - post.locale = "en" - - expect(described_class.localize(post, "en")).to eq(nil) - end - - it "returns nil if post raw is blank" do - post.raw = "" - - expect(described_class.localize(post, "ja")).to eq(nil) - end - - it "returns nil if post raw is too long" do - SiteSetting.ai_translation_max_post_length = 10 - post.raw = "This is a very long post that exceeds the limit." - - expect(described_class.localize(post, "ja")).to eq(nil) - end - - it "translates with post and locale" do - post_raw_translator_stub({ text: post.raw, target_locale: "ja", translated: translated_raw }) - - described_class.localize(post, "ja") - end - - it "normalizes dashes to underscores and symbol type for locale" do - post_raw_translator_stub({ text: post.raw, target_locale: "zh_CN", translated: "你好,世界" }) - - described_class.localize(post, "zh-CN") - end - - it "finds or creates a PostLocalization and sets its fields" do - post_raw_translator_stub({ text: post.raw, target_locale: "ja", translated: translated_raw }) - expect { - res = described_class.localize(post, target_locale) - expect(res).to be_a(PostLocalization) - expect(res).to have_attributes( - post_id: post.id, - locale: target_locale, - raw: translated_raw, - cooked: cooked, - post_version: post.version, - localizer_user_id: Discourse.system_user.id, - ) - }.to change { PostLocalization.count }.by(1) - end - - it "updates an existing PostLocalization if present" do - post_raw_translator_stub({ text: post.raw, target_locale: "ja", translated: translated_raw }) - localization = - Fabricate(:post_localization, post: post, locale: "ja", raw: "old", cooked: "old_cooked") - expect { - out = described_class.localize(post, "ja") - expect(out.id).to eq(localization.id) - expect(out.raw).to eq(translated_raw) - expect(out.cooked).to eq(cooked) - }.to_not change { PostLocalization.count } - end - end -end diff --git a/spec/lib/translation/topic_locale_detector_spec.rb b/spec/lib/translation/topic_locale_detector_spec.rb deleted file mode 100644 index 8f4c7d6d..00000000 --- a/spec/lib/translation/topic_locale_detector_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::TopicLocaleDetector do - before { enable_current_plugin } - - describe ".detect_locale" do - fab!(:topic) { Fabricate(:topic, title: "this is a cat topic", locale: nil) } - fab!(:post) { Fabricate(:post, topic:) } - - def language_detector_stub(opts) - mock = instance_double(DiscourseAi::Translation::LanguageDetector) - allow(DiscourseAi::Translation::LanguageDetector).to receive(:new).with( - opts[:text], - topic: opts[:topic], - ).and_return(mock) - allow(mock).to receive(:detect).and_return(opts[:locale]) - end - - it "returns nil if topic title is blank" do - expect(described_class.detect_locale(nil)).to eq(nil) - end - - it "updates the topic locale with the detected locale" do - language_detector_stub({ text: "This is a cat topic", locale: "zh_CN", topic: }) - expect { described_class.detect_locale(topic) }.to change { topic.reload.locale }.from( - nil, - ).to("zh_CN") - end - - it "bypasses validations when updating locale" do - topic.update_column(:title, "A") - SiteSetting.min_topic_title_length = 15 - SiteSetting.max_topic_title_length = 16 - - language_detector_stub({ text: "A", locale: "zh_CN", topic: }) - - described_class.detect_locale(topic) - expect(topic.reload.locale).to eq("zh_CN") - end - end -end diff --git a/spec/lib/translation/topic_localizer_spec.rb b/spec/lib/translation/topic_localizer_spec.rb deleted file mode 100644 index 49f513c7..00000000 --- a/spec/lib/translation/topic_localizer_spec.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Translation::TopicLocalizer do - before { enable_current_plugin } - - describe ".localize" do - fab!(:topic) do - Fabricate( - :topic, - title: "this is a cat topic :)", - excerpt: "cats are great. how many do you have?", - ) - end - let(:translator) { mock } - let(:translated_title) { "これは猫の話題です :)" } - let(:translated_excerpt) { "猫は素晴らしいですね。何匹飼っていますか?" } - let(:fancy_title) { "これは猫の話題です :slight_smile:" } - let(:target_locale) { "ja" } - - def topic_title_translator_stub(opts) - mock = instance_double(DiscourseAi::Translation::TopicTitleTranslator) - allow(DiscourseAi::Translation::TopicTitleTranslator).to receive(:new).with( - text: opts[:text], - target_locale: opts[:target_locale], - topic: opts[:topic] || topic, - ).and_return(mock) - allow(mock).to receive(:translate).and_return(opts[:translated]) - end - - def post_raw_translator_stub(opts) - mock = instance_double(DiscourseAi::Translation::PostRawTranslator) - allow(DiscourseAi::Translation::PostRawTranslator).to receive(:new).with( - text: opts[:text], - target_locale: opts[:target_locale], - topic: opts[:topic] || topic, - ).and_return(mock) - allow(mock).to receive(:translate).and_return(opts[:translated]) - end - - it "returns nil if topic is blank" do - expect(described_class.localize(nil, "ja")).to eq(nil) - end - - it "returns nil if target_locale is blank" do - expect(described_class.localize(topic, nil)).to eq(nil) - expect(described_class.localize(topic, "")).to eq(nil) - end - - it "returns nil if target_locale is same as topic locale" do - topic.locale = "en" - - expect(described_class.localize(topic, "en")).to eq(nil) - end - - it "translates with topic and locale" do - topic_title_translator_stub( - { text: topic.title, target_locale: "ja", translated: translated_title }, - ) - post_raw_translator_stub( - { text: topic.excerpt, target_locale: "ja", translated: translated_excerpt }, - ) - - described_class.localize(topic, "ja") - end - - it "normalizes dashes to underscores and symbol type for locale" do - topic_title_translator_stub( - { text: topic.title, target_locale: "zh_CN", translated: "这是一个猫主题 :)" }, - ) - post_raw_translator_stub( - { text: topic.excerpt, target_locale: "zh_CN", translated: "这是一个猫主题 :)" }, - ) - - described_class.localize(topic, "zh-CN") - end - - it "finds or creates a TopicLocalization and sets its fields" do - topic_title_translator_stub( - { text: topic.title, target_locale: "ja", translated: translated_title }, - ) - post_raw_translator_stub( - { text: topic.excerpt, target_locale: "ja", translated: translated_excerpt }, - ) - - expect { - res = described_class.localize(topic, target_locale) - expect(res).to be_a(TopicLocalization) - expect(res).to have_attributes( - topic_id: topic.id, - locale: target_locale, - title: translated_title, - excerpt: translated_excerpt, - fancy_title: fancy_title, - localizer_user_id: Discourse.system_user.id, - ) - }.to change { TopicLocalization.count }.by(1) - end - - it "updates an existing TopicLocalization if present" do - topic_title_translator_stub( - { text: topic.title, target_locale: "ja", translated: translated_title }, - ) - post_raw_translator_stub( - { text: topic.excerpt, target_locale: "ja", translated: translated_excerpt }, - ) - - localization = - Fabricate( - :topic_localization, - topic:, - locale: "ja", - title: "old title", - excerpt: "old excerpt", - fancy_title: "old_fancy_title", - ) - expect { - expect(described_class.localize(topic, "ja")).to have_attributes( - id: localization.id, - title: translated_title, - fancy_title: fancy_title, - excerpt: translated_excerpt, - ) - expect(localization.reload).to have_attributes( - title: translated_title, - fancy_title: fancy_title, - excerpt: translated_excerpt, - ) - }.to_not change { TopicLocalization.count } - end - end -end diff --git a/spec/lib/utils/ai_staff_action_logger_spec.rb b/spec/lib/utils/ai_staff_action_logger_spec.rb deleted file mode 100644 index e5f29256..00000000 --- a/spec/lib/utils/ai_staff_action_logger_spec.rb +++ /dev/null @@ -1,481 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Utils::AiStaffActionLogger do - fab!(:admin) - fab!(:llm_model) - fab!(:ai_persona) - fab!(:group) - - subject { described_class.new(admin) } - - before { enable_current_plugin } - - describe "#log_creation" do - it "logs creation of an entity with field configuration" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - # Create field configuration - field_config = { name: {}, provider: {}, url: {}, api_key: { type: :sensitive } } - - # Create entity details - entity_details = { - model_id: llm_model.id, - model_name: llm_model.name, - display_name: llm_model.display_name, - } - - # Setup model with sensitive data - llm_model.update!(api_key: "secret_key") - - subject.log_creation("llm_model", llm_model, field_config, entity_details) - - expect(staff_action_logger).to have_received(:log_custom).with( - "create_ai_llm_model", - hash_including( - "model_id" => llm_model.id, - "name" => llm_model.name, - "provider" => llm_model.provider, - "url" => llm_model.url, - "api_key" => "[FILTERED]", - ), - ) - end - - it "handles large text fields with type declaration" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - # Create a persona with a large system prompt - large_prompt = "a" * 200 - ai_persona.update!(system_prompt: large_prompt) - - # Create entity details - entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } - - field_config = { name: {}, description: {}, system_prompt: { type: :large_text } } - - subject.log_creation("persona", ai_persona, field_config, entity_details) - - # Verify with have_received - expect(staff_action_logger).to have_received(:log_custom).with( - "create_ai_persona", - hash_including( - "persona_id" => ai_persona.id, - "name" => ai_persona.name, - "system_prompt" => an_instance_of(String), - ), - ) do |action, details| - # Check that system_prompt was truncated - expect(details["system_prompt"].length).to be < 200 - end - end - - it "allows excluding fields from extraction" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - field_config = { - name: { - }, - display_name: { - }, - provider: { - extract: false, - }, # Should be excluded - url: { - }, - } - - # Create entity details - entity_details = { - model_id: llm_model.id, - model_name: llm_model.name, - display_name: llm_model.display_name, - } - - subject.log_creation("llm_model", llm_model, field_config, entity_details) - - expect(staff_action_logger).to have_received(:log_custom).with( - "create_ai_llm_model", - hash_including( - "model_id" => llm_model.id, - "name" => llm_model.name, - "display_name" => llm_model.display_name, - "url" => llm_model.url, - ), - ) do |action, details| - # Provider should not be present - expect(details).not_to have_key("provider") - end - end - end - - describe "#log_update" do - it "handles empty arrays and complex JSON properly" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - # Setup initial attributes with empty JSON arrays - initial_attributes = { "name" => "Old Name", "allowed_group_ids" => [] } - - # Update with complex JSON - ai_persona.update!(name: "New Name", allowed_group_ids: [group.id, 999]) - - field_config = { name: {}, json_fields: %w[allowed_group_ids] } - - # Create entity details - entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } - - subject.log_update("persona", ai_persona, initial_attributes, field_config, entity_details) - - # Verify with have_received - expect(staff_action_logger).to have_received(:log_custom).with( - "update_ai_persona", - hash_including( - "persona_id" => ai_persona.id, - "persona_name" => ai_persona.name, - "name" => "Old Name → New Name", - "allowed_group_ids" => "updated", - ), - ) - end - - it "logs changes to attributes based on field configuration" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - initial_attributes = { - "name" => "Old Name", - "display_name" => "Old Display Name", - "provider" => "open_ai", - "api_key" => "old_secret", - } - - llm_model.update!( - name: "New Name", - display_name: "New Display Name", - provider: "anthropic", - api_key: "new_secret", - ) - - field_config = { name: {}, display_name: {}, provider: {}, api_key: { type: :sensitive } } - - # Create entity details - entity_details = { - model_id: llm_model.id, - model_name: llm_model.name, - display_name: llm_model.display_name, - } - - subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) - - # Verify with have_received - expect(staff_action_logger).to have_received(:log_custom).with( - "update_ai_llm_model", - hash_including( - "model_id" => llm_model.id, - "name" => "Old Name → New Name", - "display_name" => "Old Display Name → New Display Name", - "provider" => "open_ai → anthropic", - "api_key" => "updated", # Not showing actual values - ), - ) - end - - it "doesn't log when there are no changes" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - initial_attributes = { - "name" => llm_model.name, - "display_name" => llm_model.display_name, - "provider" => llm_model.provider, - } - - field_config = { name: {}, display_name: {}, provider: {} } - - # Create entity details - entity_details = { - model_id: llm_model.id, - model_name: llm_model.name, - display_name: llm_model.display_name, - } - - subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) - - # Verify log_custom was not called - expect(staff_action_logger).not_to have_received(:log_custom) - end - - it "handles fields marked as not to be tracked" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - initial_attributes = { - "name" => "Old Name", - "display_name" => "Old Display Name", - "provider" => "open_ai", - } - - llm_model.update!(name: "New Name", display_name: "New Display Name", provider: "anthropic") - - field_config = { - name: { - }, - display_name: { - }, - provider: { - track: false, - }, # Should not be tracked even though it changed - } - - # Create entity details - entity_details = { - model_id: llm_model.id, - model_name: llm_model.name, - display_name: llm_model.display_name, - } - - subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) - - # Provider should not appear in the logged changes - expect(staff_action_logger).to have_received(:log_custom).with( - "update_ai_llm_model", - hash_including( - "model_id" => llm_model.id, - "name" => "Old Name → New Name", - "display_name" => "Old Display Name → New Display Name", - ), - ) do |action, details| - expect(details).not_to have_key("provider") - end - end - - it "handles json fields properly" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - # Setup initial attributes with JSON fields - initial_attributes = { - "name" => "Old Name", - "tools" => [["search", { "base_query" => "test" }, true]], - } - - # Update with different JSON - ai_persona.update!( - name: "New Name", - tools: [["search", { "base_query" => "updated" }, true], ["categories", {}, false]], - ) - - field_config = { name: {}, json_fields: %w[tools] } - - # Create entity details - entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } - - subject.log_update("persona", ai_persona, initial_attributes, field_config, entity_details) - - # Verify with have_received - expect(staff_action_logger).to have_received(:log_custom).with( - "update_ai_persona", - hash_including( - "persona_id" => ai_persona.id, - "persona_name" => ai_persona.name, - "name" => "Old Name → New Name", - "tools" => "updated", - ), - ) - end - end - - describe "#log_deletion" do - it "logs deletion with the correct entity type" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - details = { - model_id: llm_model.id, - display_name: llm_model.display_name, - name: llm_model.name, - } - - subject.log_deletion("llm_model", details) - - # Verify with have_received - expect(staff_action_logger).to have_received(:log_custom).with( - "delete_ai_llm_model", - hash_including( - "model_id" => details[:model_id], - "display_name" => details[:display_name], - "name" => details[:name], - ), - ) - end - end - - describe "#log_custom" do - it "delegates to StaffActionLogger#log_custom" do - staff_action_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) - allow(staff_action_logger).to receive(:log_custom) - - details = { key: "value" } - - subject.log_custom("custom_action_type", details) - - # Verify with have_received - expect(staff_action_logger).to have_received(:log_custom).with( - "custom_action_type", - hash_including("key" => details[:key]), - ) - end - end - - describe "Special cases from controllers" do - context "with EmbeddingDefinition" do - fab!(:embedding_definition) do - Fabricate( - :embedding_definition, - display_name: "Test Embedding", - dimensions: 768, - provider: "open_ai", - ) - end - - it "includes dimensions in logged data" do - # Setup - staff_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) - allow(staff_logger).to receive(:log_custom) - - # Create entity details - entity_details = { - embedding_id: embedding_definition.id, - subject: embedding_definition.display_name, - } - - # Field config without dimensions - field_config = { display_name: {}, provider: {}, url: {} } - - logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) - logger.log_creation("embedding", embedding_definition, field_config, entity_details) - - # Verify with have_received - expect(staff_logger).to have_received(:log_custom).with( - "create_ai_embedding", - hash_including("dimensions" => 768), - ) - end - end - - context "with LlmModel quotas" do - before do - # Create a quota for the model - @quota = Fabricate(:llm_quota, llm_model: llm_model, group: group, max_tokens: 1000) - end - - it "handles quota changes in log_llm_model_creation" do - # Setup - staff_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) - allow(staff_logger).to receive(:log_custom) - - # Call the method directly as it would be called from the controller - logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) - field_config = { display_name: {}, name: {} } - - # Create entity details - entity_details = { - model_id: llm_model.id, - model_name: llm_model.name, - display_name: llm_model.display_name, - } - - log_details = entity_details.dup - log_details.merge!(logger.send(:extract_entity_attributes, llm_model, field_config)) - - # Add quota information as a special case - log_details[:quotas] = llm_model - .llm_quotas - .map do |quota| - "Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s" - end - .join("; ") - - logger.log_custom("create_ai_llm_model", log_details) - - # Verify with have_received - expect(staff_logger).to have_received(:log_custom).with( - "create_ai_llm_model", - hash_including( - "model_id" => llm_model.id, - "model_name" => llm_model.name, - "display_name" => llm_model.display_name, - ), - ) - expect(staff_logger).to have_received(:log_custom).with( - "create_ai_llm_model", - hash_including("quotas" => a_string_including("Group #{group.id}", "1000 tokens")), - ) - end - - it "handles quota changes in log_llm_model_update" do - initial_quotas = llm_model.llm_quotas.map(&:attributes) - - # Update the quota - @quota.update!(max_tokens: 2000) - current_quotas = llm_model.llm_quotas.reload.map(&:attributes) - - # Setup - staff_logger = instance_double(StaffActionLogger) - allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) - allow(staff_logger).to receive(:log_custom) - - # Simulate the special quota handling in the controller - logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) - changes = {} - - # Track quota changes separately as they're a special case - if initial_quotas != current_quotas - initial_quota_summary = - initial_quotas - .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } - .join("; ") - current_quota_summary = - current_quotas - .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } - .join("; ") - changes[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}" - end - - # Create entity details - entity_details = { - model_id: llm_model.id, - model_name: llm_model.name, - display_name: llm_model.display_name, - } - - log_details = entity_details.dup.merge(changes) - logger.log_custom("update_ai_llm_model", log_details) - - # Verify with have_received - expect(staff_logger).to have_received(:log_custom).with( - "update_ai_llm_model", - hash_including( - "model_id" => llm_model.id, - "quotas" => a_string_including("1000 tokens", "2000 tokens"), - ), - ) - end - end - end -end diff --git a/spec/lib/utils/best_effort_json_parser_spec.rb b/spec/lib/utils/best_effort_json_parser_spec.rb deleted file mode 100644 index 002d8586..00000000 --- a/spec/lib/utils/best_effort_json_parser_spec.rb +++ /dev/null @@ -1,192 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Utils::BestEffortJsonParser do - before { enable_current_plugin } - - describe ".extract_key" do - context "with string type schema" do - let(:schema_type) { "string" } - let(:schema_key) { :output } - - it "handles JSON wrapped in markdown fences" do - input = <<~JSON - ```json - {"output": "Hello world"} - ``` - JSON - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq("Hello world") - end - - it "handles JSON with backticks but no language identifier" do - input = <<~JSON - ``` - {"output": "Test message"} - ``` - JSON - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq("Test message") - end - - it "extracts value from malformed JSON with single quotes" do - input = "{'output': 'Single quoted value'}" - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq("Single quoted value") - end - - it "extracts value from JSON with unquoted keys" do - input = "{output: \"Unquoted key value\"}" - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq("Unquoted key value") - end - - it "handles JSON with extra text before and after" do - input = <<~TEXT - Here is the result: - {"output": "Extracted value"} - That's all! - TEXT - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq("Extracted value") - end - - it "handles nested JSON structures" do - input = <<~JSON - { - "data": { - "nested": true - }, - "output": "Found me!" - } - JSON - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq("Found me!") - end - - it "handles strings with escaped quotes" do - input = '{"output": "She said \"Hello\" to me"}' - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq("She said \"Hello\" to me") - end - - it "accepts string keys as well as symbols" do - input = '{"output": "String key test"}' - result = described_class.extract_key(input, schema_type, "output") - expect(result).to eq("String key test") - end - end - - context "with array type schema" do - let(:schema_type) { "array" } - let(:schema_key) { :output } - - it "handles array wrapped in markdown fences" do - input = <<~JSON - ```json - {"output": ["item1", "item2", "item3"]} - ``` - JSON - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq(%w[item1 item2 item3]) - end - - it "extracts array from malformed JSON" do - input = "{output: ['value1', 'value2']}" - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq(%w[value1 value2]) - end - - it "handles empty arrays" do - input = <<~JSON - ```json - {"output": []} - ``` - JSON - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq([]) - end - - it "handles arrays with mixed quotes" do - input = '{output: ["item1", \'item2\']}' - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq(%w[item1 item2]) - end - - it "accepts string keys" do - input = '{"items": ["a", "b"]}' - result = described_class.extract_key(input, "array", "items") - expect(result).to eq(%w[a b]) - end - end - - context "with object type schema" do - let(:schema_type) { "object" } - let(:schema_key) { :data } - - it "extracts object from markdown fenced JSON" do - input = <<~JSON - ```json - { - "data": { - "name": "Test", - "value": 123 - } - } - ``` - JSON - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq({ "name" => "Test", "value" => 123 }) - end - - it "handles malformed object JSON" do - input = "{data: {name: 'Test', value: 123}}" - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq({ "name" => "Test", "value" => 123 }) - end - - it "handles nested objects" do - input = <<~JSON - { - "data": { - "user": { - "name": "John", - "age": 30 - }, - "active": true - } - } - JSON - result = described_class.extract_key(input, schema_type, schema_key) - expect(result).to eq({ "user" => { "name" => "John", "age" => 30 }, "active" => true }) - end - end - - context "when very broken JSON is entered" do - it "returns empty string when no valid JSON can be extracted for string type" do - input = "This is just plain text with no JSON" - result = described_class.extract_key(input, "string", :output) - expect(result).to eq("") - end - - it "returns empty array when array extraction fails" do - input = "No array here" - result = described_class.extract_key(input, "array", :output) - expect(result).to eq([]) - end - - it "returns empty hash when object extraction fails" do - input = "No object here" - result = described_class.extract_key(input, "object", :data) - expect(result).to eq({}) - end - - it "returns input as-is when it's not a string" do - expect(described_class.extract_key(123, "string", :output)).to eq(123) - expect(described_class.extract_key(["existing"], "array", :output)).to eq(["existing"]) - expect(described_class.extract_key({ existing: true }, "object", :output)).to eq( - { existing: true }, - ) - end - end - end -end diff --git a/spec/lib/utils/diff_utils/hunk_diff_spec.rb b/spec/lib/utils/diff_utils/hunk_diff_spec.rb deleted file mode 100644 index de400a98..00000000 --- a/spec/lib/utils/diff_utils/hunk_diff_spec.rb +++ /dev/null @@ -1,286 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Utils::DiffUtils::HunkDiff do - before { enable_current_plugin } - - describe ".apply_hunk" do - subject(:apply_hunk) { described_class.apply(original_text, diff) } - - context "with HTML content" do - let(:original_text) { <<~HTML } -
-

Original Title

-

Some content here

-
    -
  • Item 1
  • -
  • Item 2
  • -
-
- HTML - - context "when adding content" do - let(:diff) { <<~DIFF } -
-

Original Title

- +

New Subtitle

-

Some content here

- DIFF - - it "inserts the new content" do - expected = <<~HTML -
-

Original Title

-

New Subtitle

-

Some content here

-
    -
  • Item 1
  • -
  • Item 2
  • -
-
- HTML - expect(apply_hunk).to eq(expected.strip) - end - end - - context "when removing content" do - let(:diff) { <<~DIFF } -
    - -
  • Item 1
  • -
  • Item 2
  • - DIFF - - it "removes the specified content" do - # note how this is super forgiving - expected = <<~HTML -
    -

    Original Title

    -

    Some content here

    -
      -
    • Item 2
    • -
    -
    - HTML - expect(apply_hunk).to eq(expected.strip) - end - end - - context "when replacing content" do - let(:diff) { <<~DIFF } -
    - -

    Original Title

    - +

    Updated Title

    -

    Some content here

    - DIFF - - it "replaces the content correctly" do - expected = <<~HTML -
    -

    Updated Title

    -

    Some content here

    -
      -
    • Item 1
    • -
    • Item 2
    • -
    -
    - HTML - - expect(apply_hunk).to eq(expected.strip) - end - end - end - - context "with CSS content" do - let(:original_text) { <<~CSS } - .container { - background: #fff; - padding: 20px; - margin: 10px; - } - CSS - - context "when modifying properties" do - let(:diff) { <<~DIFF } - .container { - - background: #fff; - + background: #f5f5f5; - + happy: sam; - - padding: 20px; - + padding: 10px; - DIFF - - it "updates the property value" do - expected = <<~CSS - .container { - background: #f5f5f5; - happy: sam; - padding: 10px; - margin: 10px; - } - CSS - - expect(apply_hunk).to eq(expected.strip) - end - end - end - - context "when handling errors" do - let(:original_text) { <<~HTML } -
    -

    Title

    -

    -

    Title

    -

    -

    - HTML - - context "with ambiguous matches" do - let(:diff) { <<~DIFF } -

    Title

    - +

    Subtitle

    -

    - DIFF - - it "raises an AmbiguousMatchError" do - expect { apply_hunk }.to raise_error( - DiscourseAi::Utils::DiffUtils::HunkDiff::AmbiguousMatchError, - ) do |error| - expect(error.to_llm_message).to include("Found multiple possible locations") - end - end - end - - context "with no matching context" do - let(:diff) { <<~DIFF } -

    Wrong Title

    - +

    Subtitle

    -

    - DIFF - - it "raises a NoMatchingContextError" do - expect { apply_hunk }.to raise_error( - DiscourseAi::Utils::DiffUtils::HunkDiff::NoMatchingContextError, - ) do |error| - expect(error.to_llm_message).to include("Could not find the context lines") - end - end - end - - context "with malformed diffs" do - context "when empty" do - let(:diff) { "" } - - it "raises a MalformedDiffError" do - expect { apply_hunk }.to raise_error( - DiscourseAi::Utils::DiffUtils::HunkDiff::MalformedDiffError, - ) do |error| - expect(error.context["Issue"]).to eq("Diff is empty") - end - end - end - end - end - - context "without markers" do - let(:original_text) { "hello" } - let(:diff) { "world" } - it "will append to the end" do - expect(apply_hunk).to eq("hello\nworld") - end - end - - context "when appending text to the end of a document" do - let(:original_text) { "hello\nworld" } - - let(:diff) { <<~DIFF } - world - +123 - DIFF - - it "can append to end" do - expect(apply_hunk).to eq("hello\nworld\n123") - end - end - - context "when applying multiple hunks to a file" do - let(:original_text) { <<~TEXT } - 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - TEXT - - let(:diff) { <<~DIFF } - @@ -1,4 +1,4 @@ - 2 - - 3 - @@ -6,4 +6,4 @@ - - 7 - DIFF - - it "can apply multiple hunks" do - expected = <<~TEXT - 1 - 2 - 4 - 5 - 6 - 8 - TEXT - expect(apply_hunk).to eq(expected.strip) - end - end - - context "with line ending variations" do - let(:original_text) { "line1\r\nline2\nline3\r\n" } - let(:diff) { <<~DIFF } - line1 - +new line - line2 - DIFF - - it "handles mixed line endings" do - expect(apply_hunk).to include("new line") - expect(apply_hunk.lines.count).to eq(4) - end - end - - context "with whitespace sensitivity" do - let(:original_text) { <<~TEXT } - def method - puts "hello" - end - TEXT - - context "when indentation matters" do - let(:diff) { <<~DIFF } - def method - - puts "hello" - + puts "world" - end - DIFF - - it "preserves exact indentation" do - result = apply_hunk - expect(result).to match(/^ puts "world"$/) - end - end - - context "when trailing whitespace exists" do - let(:original_text) { "line1 \nline2\n" } - let(:diff) { <<~DIFF } - line1 - +new line - line2 - DIFF - - it "preserves significant whitespace" do - expect(apply_hunk).to include("line1 \n") - end - end - end - end -end diff --git a/spec/lib/utils/diff_utils/safety_checker_spec.rb b/spec/lib/utils/diff_utils/safety_checker_spec.rb deleted file mode 100644 index 1806ad5f..00000000 --- a/spec/lib/utils/diff_utils/safety_checker_spec.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Utils::DiffUtils::SafetyChecker do - before { enable_current_plugin } - - describe "#safe?" do - subject { described_class.new(text).safe? } - - context "with safe text" do - let(:text) { "This is a simple safe text without issues." } - - it { is_expected.to eq(true) } - - context "with normal HTML tags" do - let(:text) { "Here is bold and italic text." } - it { is_expected.to eq(true) } - end - - context "with balanced markdown and no partial emoji" do - let(:text) { "This is **bold**, *italic*, and a smiley :smile:!" } - it { is_expected.to eq(true) } - end - - context "with balanced quote blocks" do - let(:text) { "[quote]Quoted text[/quote]" } - it { is_expected.to eq(true) } - end - - context "with complete image markdown" do - let(:text) { "![alt text](https://example.com/image.png)" } - it { is_expected.to eq(true) } - end - end - - context "with unsafe text" do - context "with unclosed markdown link" do - let(:text) { "This is a [link(https://example.com)" } - it { is_expected.to eq(false) } - end - - context "with unclosed raw HTML tag" do - let(:text) { "Text with

    Page 1: LLM chunk 1Page 1: LLM chunk 2", - "Page 2: LLM chunk 3", - ] - - pages = [] - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts| - file = described_class.as_fake_file(upload: upload, user: user, llm_model: llm_model) - - while content = file.read(100_000) - pages << content - end - end - - expect(pages).to eq(["Page 1: LLM chunk 1", "Page 1: LLM chunk 2", "Page 2: LLM chunk 3"]) - end - - xit "works as expected" do - if ENV["CI"] - skip "This test requires imagemagick is installed with ghostscript support - which is not available in CI" - end - pdf_to_text = described_class.new(upload: upload, user: user, llm_model: llm_model) - pages = [] - - responses = [ - "Page 1: LLM chunk 1Page 1: LLM chunk 2", - "Page 2: LLM chunk 3", - ] - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts| - pdf_to_text.extract_text { |page| pages << page } - end - - expect(pages).to eq(["Page 1: LLM chunk 1", "Page 1: LLM chunk 2", "Page 2: LLM chunk 3"]) - end - end -end diff --git a/spec/lib/utils/research/filter_spec.rb b/spec/lib/utils/research/filter_spec.rb deleted file mode 100644 index dd239cf6..00000000 --- a/spec/lib/utils/research/filter_spec.rb +++ /dev/null @@ -1,310 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Utils::Research::Filter do - before { enable_current_plugin } - - describe "integration tests" do - before_all do - SiteSetting.min_topic_title_length = 3 - SiteSetting.min_personal_message_title_length = 3 - end - - fab!(:user) - fab!(:user2) { Fabricate(:user) } - - fab!(:feature_tag) { Fabricate(:tag, name: "feature") } - fab!(:bug_tag) { Fabricate(:tag, name: "bug") } - - fab!(:announcement_category) { Fabricate(:category, name: "Announcements") } - fab!(:feedback_category) { Fabricate(:category, name: "Feedback") } - - fab!(:group1) { Fabricate(:group, name: "group1") } - fab!(:group2) { Fabricate(:group, name: "group2") } - - fab!(:feature_topic) do - Fabricate( - :topic, - user: user, - tags: [feature_tag], - category: announcement_category, - title: "New Feature Discussion", - ) - end - - fab!(:bug_topic) do - Fabricate( - :topic, - tags: [bug_tag], - user: user, - category: announcement_category, - title: "Bug Report", - ) - end - - fab!(:feature_bug_topic) do - Fabricate( - :topic, - tags: [feature_tag, bug_tag], - user: user, - category: feedback_category, - title: "Feature with Bug", - ) - end - - fab!(:no_tag_topic) do - Fabricate(:topic, user: user, category: feedback_category, title: "General Discussion") - end - - fab!(:feature_post) { Fabricate(:post, topic: feature_topic, user: user) } - fab!(:bug_post) { Fabricate(:post, topic: bug_topic, user: user) } - fab!(:feature_bug_post) { Fabricate(:post, topic: feature_bug_topic, user: user) } - fab!(:no_tag_post) { Fabricate(:post, topic: no_tag_topic, user: user) } - fab!(:admin1) { Fabricate(:admin, username: "admin1") } - fab!(:admin2) { Fabricate(:admin, username: "admin2") } - - describe "group filtering" do - before do - group1.add(user) - group2.add(user2) - end - - it "supports filtering by groups" do - no_tag_post.update!(user_id: user2.id) - - filter = described_class.new("group:group1") - expect(filter.search.pluck(:id)).to contain_exactly( - feature_post.id, - bug_post.id, - feature_bug_post.id, - ) - - filter = described_class.new("groups:group1,group2") - expect(filter.search.pluck(:id)).to contain_exactly( - feature_post.id, - bug_post.id, - feature_bug_post.id, - no_tag_post.id, - ) - end - end - - describe "security filtering" do - fab!(:secure_group) { Fabricate(:group) } - fab!(:secure_category) { Fabricate(:category, name: "Secure") } - - fab!(:secure_topic) do - secure_category.set_permissions(secure_group => :readonly) - secure_category.save! - Fabricate( - :topic, - category: secure_category, - user: user, - title: "This is a secret Secret Topic", - ) - end - - fab!(:secure_post) { Fabricate(:post, topic: secure_topic, user: user) } - - fab!(:pm_topic) { Fabricate(:private_message_topic, user: user) } - fab!(:pm_post) { Fabricate(:post, topic: pm_topic, user: user) } - - it "omits secure categories when no guardian is supplied" do - filter = described_class.new("") - expect(filter.search.pluck(:id)).not_to include(secure_post.id) - - user.groups << secure_group - guardian = Guardian.new(user) - filter_with_guardian = described_class.new("", guardian: guardian) - expect(filter_with_guardian.search.pluck(:id)).to include(secure_post.id) - end - - it "omits PMs unconditionally" do - filter = described_class.new("") - expect(filter.search.pluck(:id)).not_to include(pm_post.id) - - guardian = Guardian.new(user) - filter_with_guardian = described_class.new("", guardian: guardian) - expect(filter_with_guardian.search.pluck(:id)).not_to include(pm_post.id) - end - end - - describe "tag filtering" do - it "correctly filters posts by tags" do - filter = described_class.new("tag:feature") - expect(filter.search.pluck(:id)).to contain_exactly(feature_post.id, feature_bug_post.id) - - filter = described_class.new("tag:feature,bug") - expect(filter.search.pluck(:id)).to contain_exactly( - feature_bug_post.id, - bug_post.id, - feature_post.id, - ) - - filter = described_class.new("tags:bug") - expect(filter.search.pluck(:id)).to contain_exactly(bug_post.id, feature_bug_post.id) - - filter = described_class.new("tag:nonexistent") - expect(filter.search.count).to eq(0) - end - end - - describe "can find posts by users even with unicode usernames" do - before { SiteSetting.unicode_usernames = true } - let!(:unicode_user) { Fabricate(:user, username: "aאb") } - - it "can filter by unicode usernames" do - post = Fabricate(:post, user: unicode_user, topic: feature_topic) - filter = described_class.new("username:aאb") - expect(filter.search.pluck(:id)).to contain_exactly(post.id) - - filter = described_class.new("usernames:aאb,#{user.username}") - posts_ids = Post.where(user_id: [unicode_user.id, user.id]).pluck(:id) - expect(filter.search.pluck(:id)).to contain_exactly(*posts_ids) - end - end - - describe "category filtering" do - it "correctly filters posts by categories" do - filter = described_class.new("category:Announcements") - expect(filter.search.pluck(:id)).to contain_exactly(feature_post.id, bug_post.id) - - # it can tack on topics - filter = - described_class.new( - "category:Announcements OR topic:#{feature_bug_post.topic.id},#{no_tag_post.topic.id}", - ) - expect(filter.search.pluck(:id)).to contain_exactly( - feature_post.id, - bug_post.id, - feature_bug_post.id, - no_tag_post.id, - ) - - filter = described_class.new("category:Announcements,Feedback") - expect(filter.search.pluck(:id)).to contain_exactly( - feature_post.id, - bug_post.id, - feature_bug_post.id, - no_tag_post.id, - ) - - filter = described_class.new("categories:Feedback") - expect(filter.search.pluck(:id)).to contain_exactly(feature_bug_post.id, no_tag_post.id) - - filter = described_class.new("category:Feedback tag:feature") - expect(filter.search.pluck(:id)).to contain_exactly(feature_bug_post.id) - end - end - - if SiteSetting.respond_to?(:assign_enabled) - describe "assign filtering" do - before do - SiteSetting.assign_enabled = true - assigner = Assigner.new(feature_topic, admin1) - assigner.assign(admin1) - - assigner = Assigner.new(bug_topic, admin1) - assigner.assign(admin2) - end - - let(:admin_guardian) { Guardian.new(admin1) } - - it "can find topics assigned to a user" do - filter = described_class.new("assigned_to:#{admin1.username}", guardian: admin_guardian) - expect(filter.search.pluck(:id)).to contain_exactly(feature_post.id) - end - - it "can find topics assigned to multiple users" do - filter = - described_class.new( - "assigned_to:#{admin1.username},#{admin2.username}", - guardian: admin_guardian, - ) - expect(filter.search.pluck(:id)).to contain_exactly(feature_post.id, bug_post.id) - end - - it "can find topics assigned to nobody" do - filter = described_class.new("assigned_to:nobody", guardian: admin_guardian) - expect(filter.search.pluck(:id)).to contain_exactly(feature_bug_post.id, no_tag_post.id) - end - - it "can find all assigned topics" do - filter = described_class.new("assigned_to:*", guardian: admin_guardian) - expect(filter.search.pluck(:id)).to contain_exactly(feature_post.id, bug_post.id) - end - - it "raises an error if assigns are disabled" do - SiteSetting.assign_enabled = false - filter = described_class.new("assigned_to:sam") - expect { filter.search }.to raise_error(Discourse::InvalidAccess) - end - end - end - - it "can limit number of results" do - filter = described_class.new("category:Feedback max_results:1", limit: 5) - expect(filter.search.pluck(:id).length).to eq(1) - end - - describe "full text keyword searching" do - before_all { SearchIndexer.enable } - fab!(:post_with_apples) do - Fabricate(:post, raw: "This post contains apples", topic: feature_topic, user: user) - end - - fab!(:post_with_bananas) do - Fabricate(:post, raw: "This post mentions bananas", topic: bug_topic, user: user) - end - - fab!(:post_with_both) do - Fabricate( - :post, - raw: "This post has apples and bananas", - topic: feature_bug_topic, - user: user, - ) - end - - fab!(:post_with_none) do - Fabricate(:post, raw: "No fruits here", topic: no_tag_topic, user: user) - end - - fab!(:reply_on_bananas) do - Fabricate(:post, raw: "Just a reply", topic: post_with_bananas.topic, user: user) - end - - it "correctly filters posts by topic_keywords" do - topic1 = post_with_bananas.topic - topic2 = post_with_both.topic - - filter = described_class.new("topic_keywords:banana") - expected = topic1.posts.pluck(:id) + topic2.posts.pluck(:id) - expect(filter.search.pluck(:id)).to contain_exactly(*expected) - - filter = described_class.new("topic_keywords:banana post_type:first") - expect(filter.search.pluck(:id)).to contain_exactly( - topic1.posts.order(:post_number).first.id, - topic2.posts.order(:post_number).first.id, - ) - end - - it "correctly filters posts by full text keywords" do - filter = described_class.new("keywords:apples") - expect(filter.search.pluck(:id)).to contain_exactly(post_with_apples.id, post_with_both.id) - - filter = described_class.new("keywords:bananas") - expect(filter.search.pluck(:id)).to contain_exactly(post_with_bananas.id, post_with_both.id) - - filter = described_class.new("keywords:apples,bananas") - expect(filter.search.pluck(:id)).to contain_exactly( - post_with_apples.id, - post_with_bananas.id, - post_with_both.id, - ) - - filter = described_class.new("keywords:oranges") - expect(filter.search.count).to eq(0) - end - end - end -end diff --git a/spec/lib/utils/research/llm_formatter_spec.rb b/spec/lib/utils/research/llm_formatter_spec.rb deleted file mode 100644 index 3021ccc7..00000000 --- a/spec/lib/utils/research/llm_formatter_spec.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true -# -describe DiscourseAi::Utils::Research::LlmFormatter do - fab!(:user) { Fabricate(:user, username: "test_user") } - fab!(:topic) { Fabricate(:topic, title: "This is a Test Topic", user: user) } - fab!(:post) { Fabricate(:post, topic: topic, user: user) } - let(:tokenizer) { DiscourseAi::Tokenizer::OpenAiTokenizer } - let(:filter) { DiscourseAi::Utils::Research::Filter.new("@#{user.username}") } - - before { enable_current_plugin } - - describe "#truncate_if_needed" do - it "returns original content when under token limit" do - formatter = - described_class.new( - filter, - max_tokens_per_batch: 1000, - tokenizer: tokenizer, - max_tokens_per_post: 100, - ) - - short_text = "This is a short post" - expect(formatter.send(:truncate_if_needed, short_text)).to eq(short_text) - end - - it "truncates content when over token limit" do - # Create a post with content that will exceed our token limit - long_text = ("word " * 200).strip - - formatter = - described_class.new( - filter, - max_tokens_per_batch: 1000, - tokenizer: tokenizer, - max_tokens_per_post: 50, - ) - - truncated = formatter.send(:truncate_if_needed, long_text) - - expect(truncated).to include("... elided 150 tokens ...") - expect(truncated).to_not eq(long_text) - - # Should have roughly 25 words before and 25 after (half of max_tokens_per_post) - first_chunk = truncated.split("\n\n")[0] - expect(first_chunk.split(" ").length).to be_within(5).of(25) - - last_chunk = truncated.split("\n\n")[2] - expect(last_chunk.split(" ").length).to be_within(5).of(25) - end - end - - describe "#format_post" do - it "formats posts with truncation for long content" do - # Set up a post with long content - long_content = ("word " * 200).strip - long_post = Fabricate(:post, raw: long_content, topic: topic, user: user) - - formatter = - described_class.new( - filter, - max_tokens_per_batch: 1000, - tokenizer: tokenizer, - max_tokens_per_post: 50, - ) - - formatted = formatter.send(:format_post, long_post) - - # Should have standard formatting elements - expect(formatted).to include("## Post by #{user.username}") - expect(formatted).to include("Post url: /t/-/#{long_post.topic_id}/#{long_post.post_number}") - - # Should include truncation marker - expect(formatted).to include("... elided 150 tokens ...") - end - end -end diff --git a/spec/lib/utils/search_spec.rb b/spec/lib/utils/search_spec.rb deleted file mode 100644 index a61449b0..00000000 --- a/spec/lib/utils/search_spec.rb +++ /dev/null @@ -1,220 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Utils::Search do - before do - enable_current_plugin - SearchIndexer.enable - end - - after { SearchIndexer.disable } - - fab!(:admin) - fab!(:user) - fab!(:group) - fab!(:parent_category) { Fabricate(:category, name: "animals") } - fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") } - fab!(:tag_funny) { Fabricate(:tag, name: "funny") } - fab!(:tag_sad) { Fabricate(:tag, name: "sad") } - fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") } - fab!(:staff_tag_group) do - tag_group = Fabricate.build(:tag_group, name: "Staff only", tag_names: ["hidden"]) - - tag_group.permissions = [ - [Group::AUTO_GROUPS[:staff], TagGroupPermission.permission_types[:full]], - ] - tag_group.save! - tag_group - end - - fab!(:topic_with_tags) do - Fabricate(:topic, category: category, tags: [tag_funny, tag_sad, tag_hidden]) - end - - fab!(:private_category) do - c = Fabricate(:category_with_definition) - c.set_permissions(group => :readonly) - c.save - c - end - - describe ".perform_search" do - it "returns search results with correct format" do - post = Fabricate(:post, topic: topic_with_tags) - - results = - described_class.perform_search( - search_query: post.raw, - user: post.user.username, - current_user: admin, - ) - - expect(results).to have_key(:args) - expect(results).to have_key(:rows) - expect(results).to have_key(:column_names) - expect(results[:rows].length).to eq(1) - end - - it "handles no results" do - results = - described_class.perform_search( - search_query: "NONEXISTENTTERMNOONEWOULDSEARCH", - current_user: admin, - ) - - expect(results[:rows]).to eq([]) - expect(results[:instruction]).to eq("nothing was found, expand your search") - end - - it "returns private results when user has access" do - private_post = Fabricate(:post, topic: Fabricate(:topic, category: private_category)) - - # Regular user without access - results = described_class.perform_search(search_query: private_post.raw, current_user: user) - expect(results[:rows].length).to eq(0) - - # Add user to group with access - GroupUser.create!(group: group, user: user) - - # Now should find the private post - results = - described_class.perform_search( - search_query: private_post.raw, - current_user: user, - result_style: :detailed, - ) - expect(results[:rows].length).to eq(1) - # so API is less confusing - expect(results.key?(:column_names)).to eq(false) - - results = - described_class.perform_search( - search_query: private_post.raw, - current_user: user, - result_style: :compact, - ) - - expect(results[:rows].length).to eq(1) - # so API is less confusing - expect(results[:column_names]).to be_present - end - - it "properly handles subfolder URLs" do - Discourse.stubs(:base_path).returns("/subfolder") - - post = Fabricate(:post, topic: topic_with_tags) - - results = described_class.perform_search(search_query: post.raw, current_user: admin) - - url_index = results[:column_names].index("url") - expect(results[:rows][0][url_index]).to include("/subfolder") - end - - it "returns rich topic information" do - post = Fabricate(:post, like_count: 1, topic: topic_with_tags) - post.topic.update!(views: 100, posts_count: 2, like_count: 10) - - results = described_class.perform_search(search_query: post.raw, current_user: admin) - - row = results[:rows].first - - category_index = results[:column_names].index("category") - expect(row[category_index]).to eq("animals > amazing-cat") - - tags_index = results[:column_names].index("tags") - expect(row[tags_index]).to eq("funny, sad") - - likes_index = results[:column_names].index("likes") - expect(row[likes_index]).to eq(1) - - topic_likes_index = results[:column_names].index("topic_likes") - expect(row[topic_likes_index]).to eq(10) - - topic_views_index = results[:column_names].index("topic_views") - expect(row[topic_views_index]).to eq(100) - - topic_replies_index = results[:column_names].index("topic_replies") - expect(row[topic_replies_index]).to eq(1) - end - - context "when using semantic search" do - let(:query) { "this is an expanded search" } - after do - if defined?(DiscourseAi::Embeddings::SemanticSearch) - DiscourseAi::Embeddings::SemanticSearch.clear_cache_for(query) - end - end - - it "includes semantic search results when enabled" do - assign_fake_provider_to(:ai_embeddings_semantic_search_hyde_model) - vector_def = Fabricate(:embedding_definition) - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_semantic_search_enabled = true - - hyde_embedding = [0.049382] * vector_def.dimensions - EmbeddingsGenerationStubs.hugging_face_service(query, hyde_embedding) - - post = Fabricate(:post, topic: topic_with_tags) - DiscourseAi::Embeddings::Schema.for(Topic).store(post.topic, hyde_embedding, "digest") - - # Using a completely different search query, should still find via semantic search - results = - DiscourseAi::Completions::Llm.with_prepared_responses([query]) do - described_class.perform_search( - search_query: "totally different query", - current_user: admin, - ) - end - - expect(results[:rows].length).to eq(1) - end - - it "can disable semantic search with hyde parameter" do - assign_fake_provider_to(:ai_embeddings_semantic_search_hyde_model) - vector_def = Fabricate(:embedding_definition) - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_semantic_search_enabled = true - - embedding = [0.049382] * vector_def.dimensions - EmbeddingsGenerationStubs.hugging_face_service(query, embedding) - - post = Fabricate(:post, topic: topic_with_tags) - DiscourseAi::Embeddings::Schema.for(Topic).store(post.topic, embedding, "digest") - - WebMock - .stub_request(:post, "https://test.com/embeddings") - .with(body: "{\"inputs\":\"totally different query\",\"truncate\":true}") - .to_return(status: 200, body: embedding.to_json) - - results = - described_class.perform_search( - search_query: "totally different query", - hyde: false, - current_user: admin, - ) - - expect(results[:rows].length).to eq(0) - end - end - - it "passes all search parameters to the results args" do - post = Fabricate(:post, topic: topic_with_tags) - - search_params = { - search_query: post.raw, - category: category.name, - user: post.user.username, - order: "latest", - max_posts: 10, - tags: tag_funny.name, - before: "2030-01-01", - after: "2000-01-01", - status: "public", - max_results: 15, - } - - results = described_class.perform_search(**search_params, current_user: admin) - - expect(results[:args]).to include(search_params) - end - end -end diff --git a/spec/models/ai_artifact_key_value_spec.rb b/spec/models/ai_artifact_key_value_spec.rb deleted file mode 100644 index c93ed925..00000000 --- a/spec/models/ai_artifact_key_value_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe AiArtifactKeyValue, type: :model do - fab!(:user) - fab!(:ai_artifact) - - before { enable_current_plugin } - - describe "#validate_max_keys_per_user_per_artifact" do - before { SiteSetting.ai_artifact_max_keys_per_user_per_artifact = 2 } - - it "prevents creation when at the limit" do - 2.times do |i| - described_class.create!( - ai_artifact: ai_artifact, - user: user, - key: "key_#{i}", - value: "value_#{i}", - ) - end - - new_record = - described_class.new( - ai_artifact: ai_artifact, - user: user, - key: "new_key", - value: "new_value", - ) - expect(new_record).not_to be_valid - expect(new_record.errors[:base]).to include( - I18n.t("discourse_ai.ai_artifact.errors.max_keys_exceeded", count: 2), - ) - end - end -end diff --git a/spec/models/ai_persona_multisite_spec.rb b/spec/models/ai_persona_multisite_spec.rb deleted file mode 100644 index d2c15085..00000000 --- a/spec/models/ai_persona_multisite_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe AiPersona, type: :multisite do - before { enable_current_plugin } - - it "is able to amend settings on system personas on multisite" do - persona = AiPersona.find_by(name: "Designer") - expect(persona.allow_personal_messages).to eq(true) - persona.update!(allow_personal_messages: false) - - instance = persona.class_instance - expect(instance.allow_personal_messages).to eq(false) - - test_multisite_connection("second") do - persona = AiPersona.find_by(name: "Designer") - expect(persona.allow_personal_messages).to eq(true) - instance = persona.class_instance - expect(instance.name).to eq("Designer") - expect(instance.allow_personal_messages).to eq(true) - end - - persona = AiPersona.find_by(name: "Designer") - instance = persona.class_instance - expect(instance.allow_personal_messages).to eq(false) - end -end diff --git a/spec/models/ai_persona_spec.rb b/spec/models/ai_persona_spec.rb deleted file mode 100644 index d93dbf34..00000000 --- a/spec/models/ai_persona_spec.rb +++ /dev/null @@ -1,325 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe AiPersona do - subject(:basic_persona) do - AiPersona.new( - name: "test", - description: "test", - system_prompt: "test", - tools: [], - allowed_group_ids: [], - ) - end - - fab!(:llm_model) - fab!(:seeded_llm_model) { Fabricate(:llm_model, id: -1) } - - before { enable_current_plugin } - - it "validates context settings" do - expect(basic_persona.valid?).to eq(true) - - basic_persona.max_context_posts = 0 - expect(basic_persona.valid?).to eq(false) - expect(basic_persona.errors[:max_context_posts]).to eq(["must be greater than 0"]) - - basic_persona.max_context_posts = 1 - expect(basic_persona.valid?).to eq(true) - - basic_persona.max_context_posts = nil - expect(basic_persona.valid?).to eq(true) - end - - it "validates tools" do - Fabricate(:ai_tool, id: 1) - Fabricate(:ai_tool, id: 2, name: "Archie search", tool_name: "search") - - expect(basic_persona.valid?).to eq(true) - - basic_persona.tools = %w[search image_generation] - expect(basic_persona.valid?).to eq(true) - - basic_persona.tools = %w[search image_generation search] - expect(basic_persona.valid?).to eq(false) - expect(basic_persona.errors[:tools]).to eq(["Can not have duplicate tools"]) - - basic_persona.tools = [ - ["custom-1", { test: "test" }, false], - ["custom-2", { test: "test" }, false], - ] - expect(basic_persona.valid?).to eq(true) - expect(basic_persona.errors[:tools]).to eq([]) - - basic_persona.tools = [ - ["custom-1", { test: "test" }, false], - ["custom-1", { test: "test" }, false], - ] - expect(basic_persona.valid?).to eq(false) - expect(basic_persona.errors[:tools]).to eq(["Can not have duplicate tools"]) - - basic_persona.tools = [ - ["custom-1", { test: "test" }, false], - ["custom-2", { test: "test" }, false], - "image_generation", - ] - expect(basic_persona.valid?).to eq(true) - expect(basic_persona.errors[:tools]).to eq([]) - - basic_persona.tools = [ - ["custom-1", { test: "test" }, false], - ["custom-2", { test: "test" }, false], - "Search", - ] - expect(basic_persona.valid?).to eq(false) - expect(basic_persona.errors[:tools]).to eq(["Can not have duplicate tools"]) - end - - it "allows creation of user" do - user = basic_persona.create_user! - expect(user.username).to eq("test_bot") - expect(user.name).to eq("Test") - expect(user.bot?).to be(true) - expect(user.id).to be <= AiPersona::FIRST_PERSONA_USER_ID - end - - it "removes all rag embeddings when rag params change" do - persona = - AiPersona.create!( - name: "test", - description: "test", - system_prompt: "test", - tools: [], - allowed_group_ids: [], - rag_chunk_tokens: 10, - rag_chunk_overlap_tokens: 5, - ) - - id = - RagDocumentFragment.create!( - target: persona, - fragment: "test", - fragment_number: 1, - upload: Fabricate(:upload), - ).id - - persona.rag_chunk_tokens = 20 - persona.save! - - expect(RagDocumentFragment.exists?(id)).to eq(false) - end - - it "defines singleton methods on system persona classes" do - forum_helper = AiPersona.find_by(name: "Forum Helper") - forum_helper.update!( - user_id: 1, - default_llm_id: llm_model.id, - max_context_posts: 3, - allow_topic_mentions: true, - allow_personal_messages: true, - allow_chat_channel_mentions: true, - allow_chat_direct_messages: true, - ) - - klass = forum_helper.class_instance - - expect(klass.id).to eq(forum_helper.id) - expect(klass.system).to eq(true) - # tl 0 by default - expect(klass.allowed_group_ids).to eq([10]) - expect(klass.user_id).to eq(1) - expect(klass.default_llm_id).to eq(llm_model.id) - expect(klass.max_context_posts).to eq(3) - expect(klass.allow_topic_mentions).to eq(true) - expect(klass.allow_personal_messages).to eq(true) - expect(klass.allow_chat_channel_mentions).to eq(true) - expect(klass.allow_chat_direct_messages).to eq(true) - end - - it "defines singleton methods non persona classes" do - persona = - AiPersona.create!( - name: "test", - description: "test", - system_prompt: "test", - tools: [], - allowed_group_ids: [], - default_llm_id: llm_model.id, - max_context_posts: 3, - allow_topic_mentions: true, - allow_personal_messages: true, - allow_chat_channel_mentions: true, - allow_chat_direct_messages: true, - user_id: 1, - ) - - klass = persona.class_instance - - expect(klass.id).to eq(persona.id) - expect(klass.system).to eq(false) - expect(klass.allowed_group_ids).to eq([]) - expect(klass.user_id).to eq(1) - expect(klass.default_llm_id).to eq(llm_model.id) - expect(klass.max_context_posts).to eq(3) - expect(klass.allow_topic_mentions).to eq(true) - expect(klass.allow_personal_messages).to eq(true) - expect(klass.allow_chat_channel_mentions).to eq(true) - expect(klass.allow_chat_direct_messages).to eq(true) - end - - it "does not allow setting allowing chat without a default_llm" do - persona = - AiPersona.create( - name: "test", - description: "test", - system_prompt: "test", - allowed_group_ids: [], - default_llm: nil, - allow_chat_channel_mentions: true, - ) - - expect(persona.valid?).to eq(false) - expect(persona.errors[:default_llm].first).to eq( - I18n.t("discourse_ai.ai_bot.personas.default_llm_required"), - ) - - persona = - AiPersona.create( - name: "test", - description: "test", - system_prompt: "test", - allowed_group_ids: [], - default_llm: nil, - allow_chat_direct_messages: true, - ) - - expect(persona.valid?).to eq(false) - expect(persona.errors[:default_llm].first).to eq( - I18n.t("discourse_ai.ai_bot.personas.default_llm_required"), - ) - - persona = - AiPersona.create( - name: "test", - description: "test", - system_prompt: "test", - allowed_group_ids: [], - default_llm: nil, - allow_topic_mentions: true, - ) - - expect(persona.valid?).to eq(false) - expect(persona.errors[:default_llm].first).to eq( - I18n.t("discourse_ai.ai_bot.personas.default_llm_required"), - ) - end - - it "validates allowed seeded model" do - basic_persona.default_llm_id = seeded_llm_model.id - - SiteSetting.ai_bot_allowed_seeded_models = "" - - expect(basic_persona.valid?).to eq(false) - expect(basic_persona.errors[:default_llm]).to include( - I18n.t("discourse_ai.llm.configuration.invalid_seeded_model"), - ) - - SiteSetting.ai_bot_allowed_seeded_models = "-1" - expect(basic_persona.valid?).to eq(true) - end - - it "does not leak caches between sites" do - AiPersona.create!( - name: "pun_bot", - description: "you write puns", - system_prompt: "you are pun bot", - tools: ["ImageCommand"], - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - ) - - AiPersona.all_personas - - expect(AiPersona.persona_cache[:value].length).to be > (0) - RailsMultisite::ConnectionManagement.stubs(:current_db) { "abc" } - expect(AiPersona.persona_cache[:value]).to eq(nil) - end - - describe "system persona validations" do - let(:system_persona) do - AiPersona.create!( - name: "system_persona", - description: "system persona", - system_prompt: "system persona", - tools: %w[Search Time], - response_format: [{ key: "summary", type: "string" }], - examples: [%w[user_msg1 assistant_msg1], %w[user_msg2 assistant_msg2]], - system: true, - ) - end - - context "when modifying a system persona" do - it "allows changing tool options without allowing tool additions/removals" do - tools = [["Search", { "base_query" => "abc" }], ["Time"]] - system_persona.update!(tools: tools) - - system_persona.reload - expect(system_persona.tools).to eq(tools) - - invalid_tools = ["Time"] - system_persona.update(tools: invalid_tools) - expect(system_persona.errors[:base]).to include( - I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"), - ) - end - - it "doesn't accept response format changes" do - new_format = [{ key: "summary2", type: "string" }] - - expect { system_persona.update!(response_format: new_format) }.to raise_error( - ActiveRecord::RecordInvalid, - ) - end - - it "doesn't accept additional format changes" do - new_format = [{ key: "summary", type: "string" }, { key: "summary2", type: "string" }] - - expect { system_persona.update!(response_format: new_format) }.to raise_error( - ActiveRecord::RecordInvalid, - ) - end - - it "doesn't accept changes to examples" do - other_examples = [%w[user_msg1 assistant_msg1]] - - expect { system_persona.update!(examples: other_examples) }.to raise_error( - ActiveRecord::RecordInvalid, - ) - end - end - end - - describe "validates examples format" do - it "doesn't accept examples that are not arrays" do - basic_persona.examples = [1] - - expect(basic_persona.valid?).to eq(false) - expect(basic_persona.errors[:examples].first).to eq( - I18n.t("discourse_ai.personas.malformed_examples"), - ) - end - - it "doesn't accept examples that don't come in pairs" do - basic_persona.examples = [%w[user_msg1]] - - expect(basic_persona.valid?).to eq(false) - expect(basic_persona.errors[:examples].first).to eq( - I18n.t("discourse_ai.personas.malformed_examples"), - ) - end - - it "works when example is well formatted" do - basic_persona.examples = [%w[user_msg1 assistant1]] - - expect(basic_persona.valid?).to eq(true) - end - end -end diff --git a/spec/models/ai_tool_spec.rb b/spec/models/ai_tool_spec.rb deleted file mode 100644 index 9417f241..00000000 --- a/spec/models/ai_tool_spec.rb +++ /dev/null @@ -1,1181 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe AiTool do - fab!(:llm_model) { Fabricate(:llm_model, name: "claude-2") } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } - fab!(:topic) - fab!(:post) { Fabricate(:post, topic: topic, raw: "bananas are a tasty fruit") } - fab!(:bot_user) { Discourse.system_user } - - def create_tool( - parameters: nil, - script: nil, - rag_chunk_tokens: nil, - rag_chunk_overlap_tokens: nil - ) - AiTool.create!( - name: "test #{SecureRandom.uuid}", - tool_name: "test_#{SecureRandom.uuid.underscore}", - description: "test", - parameters: - parameters || [{ name: "query", type: "string", description: "perform a search" }], - script: script || "function invoke(params) { return params; }", - created_by_id: 1, - summary: "Test tool summary", - rag_chunk_tokens: rag_chunk_tokens || 374, - rag_chunk_overlap_tokens: rag_chunk_overlap_tokens || 10, - ) - end - - before { enable_current_plugin } - - it "it can run a basic tool" do - tool = create_tool - - expect(tool.signature).to eq( - { - name: tool.tool_name, - description: "test", - parameters: [{ name: "query", type: "string", description: "perform a search" }], - }, - ) - - runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) - - expect(runner.invoke).to eq("query" => "test") - end - - it "can base64 encode binary HTTP responses" do - # Create binary data with all possible byte values (0-255) - binary_data = (0..255).map(&:chr).join - expected_base64 = Base64.strict_encode64(binary_data) - - script = <<~JS - function invoke(params) { - const result = http.post("https://example.com/binary", { - body: "test", - base64Encode: true - }); - return result.body; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({}, llm: nil, bot_user: nil) - - stub_request(:post, "https://example.com/binary").to_return( - status: 200, - body: binary_data, - headers: { - }, - ) - - result = runner.invoke - - expect(result).to eq(expected_base64) - # Verify we can decode back to original binary data - expect(Base64.strict_decode64(result).bytes).to eq((0..255).to_a) - end - - it "can base64 encode binary GET responses" do - # Create binary data with all possible byte values (0-255) - binary_data = (0..255).map(&:chr).join - expected_base64 = Base64.strict_encode64(binary_data) - - script = <<~JS - function invoke(params) { - const result = http.get("https://example.com/binary", { - base64Encode: true - }); - return result.body; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({}, llm: nil, bot_user: nil) - - stub_request(:get, "https://example.com/binary").to_return( - status: 200, - body: binary_data, - headers: { - }, - ) - - result = runner.invoke - - expect(result).to eq(expected_base64) - # Verify we can decode back to original binary data - expect(Base64.strict_decode64(result).bytes).to eq((0..255).to_a) - end - - it "can perform HTTP requests with various verbs" do - %i[post put delete patch].each do |verb| - script = <<~JS - function invoke(params) { - result = http.#{verb}("https://example.com/api", - { - headers: { TestHeader: "TestValue" }, - body: JSON.stringify({ data: params.data }) - } - ); - - return result.body; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "data" => "test data" }, llm: nil, bot_user: nil) - - stub_request(verb, "https://example.com/api").with( - body: "{\"data\":\"test data\"}", - headers: { - "Accept" => "*/*", - "Testheader" => "TestValue", - "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)", - }, - ).to_return(status: 200, body: "Success", headers: {}) - - result = runner.invoke - - expect(result).to eq("Success") - end - end - - it "can perform GET HTTP requests, with 1 param" do - script = <<~JS - function invoke(params) { - result = http.get("https://example.com/" + params.query); - return result.body; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) - - stub_request(:get, "https://example.com/test").with( - headers: { - "Accept" => "*/*", - "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)", - }, - ).to_return(status: 200, body: "Hello World", headers: {}) - - result = runner.invoke - - expect(result).to eq("Hello World") - end - - it "is limited to MAX http requests" do - script = <<~JS - function invoke(params) { - let i = 0; - while (i < 21) { - http.get("https://example.com/"); - i += 1; - } - return "will not happen"; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({}, llm: nil, bot_user: nil) - - stub_request(:get, "https://example.com/").to_return( - status: 200, - body: "Hello World", - headers: { - }, - ) - - expect { runner.invoke }.to raise_error(DiscourseAi::Personas::ToolRunner::TooManyRequestsError) - end - - it "can perform GET HTTP requests" do - script = <<~JS - function invoke(params) { - result = http.get("https://example.com/" + params.query, - { headers: { TestHeader: "TestValue" } } - ); - - return result.body; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) - - stub_request(:get, "https://example.com/test").with( - headers: { - "Accept" => "*/*", - "Testheader" => "TestValue", - "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)", - }, - ).to_return(status: 200, body: "Hello World", headers: {}) - - result = runner.invoke - - expect(result).to eq("Hello World") - end - - it "will not timeout on slow HTTP reqs" do - script = <<~JS - function invoke(params) { - result = http.get("https://example.com/" + params.query, - { headers: { TestHeader: "TestValue" } } - ); - - return result.body; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) - - stub_request(:get, "https://example.com/test").to_return do - sleep 0.01 - { status: 200, body: "Hello World", headers: {} } - end - - tool = create_tool(script: script) - runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) - - runner.timeout = 10 - - result = runner.invoke - - expect(result).to eq("Hello World") - end - - it "has access to llm truncation tools" do - script = <<~JS - function invoke(params) { - return llm.truncate("Hello World", 1); - } - JS - - tool = create_tool(script: script) - - runner = tool.runner({}, llm: llm, bot_user: nil) - result = runner.invoke - - expect(result).to eq("Hello") - end - - it "is able to run llm completions" do - script = <<~JS - function invoke(params) { - return llm.generate("question two") + llm.generate( - { messages: [ - { type: "system", content: "system message" }, - { type: "user", content: "user message" } - ]} - ); - } - JS - - tool = create_tool(script: script) - - result = nil - prompts = nil - responses = ["Hello ", "World"] - - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts| - runner = tool.runner({}, llm: llm, bot_user: nil) - result = runner.invoke - prompts = _prompts - end - - prompt = - DiscourseAi::Completions::Prompt.new( - "system message", - messages: [{ type: :user, content: "user message" }], - ) - expect(result).to eq("Hello World") - expect(prompts[0]).to eq("question two") - expect(prompts[1]).to eq(prompt) - end - - it "can timeout slow JS" do - script = <<~JS - function invoke(params) { - while (true) {} - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) - - runner.timeout = 5 - - result = runner.invoke - expect(result[:error]).to eq("Script terminated due to timeout") - end - - context "when defining RAG fragments" do - fab!(:cloudflare_embedding_def) - - before do - SiteSetting.authorized_extensions = "txt" - SiteSetting.ai_embeddings_selected_model = cloudflare_embedding_def.id - SiteSetting.ai_embeddings_enabled = true - Jobs.run_immediately! - end - - def create_upload(content, filename) - upload = nil - Tempfile.create(filename) do |file| - file.write(content) - file.rewind - - upload = UploadCreator.new(file, filename).create_for(Discourse.system_user.id) - end - upload - end - - def stub_embeddings - # this is a trick, we get ever increasing embeddings, this gives us in turn - # 100% consistent search results - @counter = 0 - stub_request(:post, cloudflare_embedding_def.url).to_return( - status: 200, - body: lambda { |req| { result: { data: [([@counter += 2] * 1024)] } }.to_json }, - headers: { - }, - ) - end - - it "allows search within uploads" do - stub_embeddings - - upload1 = create_upload(<<~TXT, "test.txt") - 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 - TXT - - upload2 = create_upload(<<~TXT, "test.txt") - 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 - TXT - - tool = create_tool(rag_chunk_tokens: 10, rag_chunk_overlap_tokens: 4, script: <<~JS) - function invoke(params) { - let result1 = index.search("testing a search", { limit: 1 }); - let result2 = index.search("testing another search", { limit: 3, filenames: ["test.txt"] }); - - return [result1, result2]; - } - JS - - RagDocumentFragment.link_target_and_uploads(tool, [upload1.id, upload2.id]) - - result = tool.runner({}, llm: nil, bot_user: nil).invoke - - expected = [ - [{ "fragment" => "44 45 46 47 48 49 50", "metadata" => nil }], - [ - { "fragment" => "44 45 46 47 48 49 50", "metadata" => nil }, - { "fragment" => "36 37 38 39 40 41 42 43 44 45", "metadata" => nil }, - { "fragment" => "30 31 32 33 34 35 36 37", "metadata" => nil }, - ], - ] - - expect(result).to eq(expected) - - # will force a reindex - tool.rag_chunk_tokens = 5 - tool.rag_chunk_overlap_tokens = 2 - tool.save! - - # this part of the API is a bit awkward, maybe we should do it - # automatically - RagDocumentFragment.update_target_uploads(tool, [upload1.id, upload2.id]) - result = tool.runner({}, llm: nil, bot_user: nil).invoke - - # this is flaking, it is not critical cause it relies on vector search - # that may not be 100% deterministic - - # expected = [ - # [{ "fragment" => "48 49 50", "metadata" => nil }], - # [ - # { "fragment" => "48 49 50", "metadata" => nil }, - # { "fragment" => "45 46 47", "metadata" => nil }, - # { "fragment" => "42 43 44", "metadata" => nil }, - # ], - # ] - - expect(result.length).to eq(2) - expect(result[0][0]["fragment"].length).to eq(8) - expect(result[1].length).to eq(3) - end - end - - context "when using the topic API" do - it "can fetch topic details" do - script = <<~JS - function invoke(params) { - return discourse.getTopic(params.topic_id); - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "topic_id" => topic.id }, llm: nil, bot_user: nil) - - result = runner.invoke - - expect(result["id"]).to eq(topic.id) - expect(result["title"]).to eq(topic.title) - expect(result["archetype"]).to eq("regular") - expect(result["posts_count"]).to eq(1) - end - end - - context "when using the post API" do - it "can fetch post details" do - script = <<~JS - function invoke(params) { - const post = discourse.getPost(params.post_id); - return { - post: post, - topic: post.topic - } - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "post_id" => post.id }, llm: nil, bot_user: nil) - - result = runner.invoke - post_hash = result["post"] - topic_hash = result["topic"] - - expect(post_hash["id"]).to eq(post.id) - expect(post_hash["topic_id"]).to eq(topic.id) - expect(post_hash["raw"]).to eq(post.raw) - - expect(topic_hash["id"]).to eq(topic.id) - end - end - - context "when using the search API" do - before { SearchIndexer.enable } - after { SearchIndexer.disable } - - it "can perform a discourse search" do - SearchIndexer.index(topic, force: true) - SearchIndexer.index(post, force: true) - - script = <<~JS - function invoke(params) { - return discourse.search({ search_query: params.query }); - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "query" => "banana" }, llm: nil, bot_user: nil) - - result = runner.invoke - - expect(result["rows"].length).to be > 0 - expect(result["rows"].first["title"]).to eq(topic.title) - end - end - - context "when using the chat API" do - before(:each) do - skip "Chat plugin tests skipped because Chat module is not defined." unless defined?(Chat) - SiteSetting.chat_enabled = true - end - - fab!(:chat_user) { Fabricate(:user) } - fab!(:chat_channel) do - Fabricate(:chat_channel).tap do |channel| - Fabricate( - :user_chat_channel_membership, - user: chat_user, - chat_channel: channel, - following: true, - ) - end - end - - it "can create a chat message" do - script = <<~JS - function invoke(params) { - return discourse.createChatMessage({ - channel_name: params.channel_name, - username: params.username, - message: params.message - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { - "channel_name" => chat_channel.name, - "username" => chat_user.username, - "message" => "Hello from the tool!", - }, - llm: nil, - bot_user: bot_user, # The user *running* the tool doesn't affect sender - ) - - initial_message_count = Chat::Message.count - result = runner.invoke - - expect(result["success"]).to eq(true), "Tool invocation failed: #{result["error"]}" - expect(result["message"]).to eq("Hello from the tool!") - expect(result["created_at"]).to be_present - expect(result).not_to have_key("error") - - # Verify message was actually created in the database - expect(Chat::Message.count).to eq(initial_message_count + 1) - created_message = Chat::Message.find_by(id: result["message_id"]) - - expect(created_message).not_to be_nil - expect(created_message.message).to eq("Hello from the tool!") - expect(created_message.user_id).to eq(chat_user.id) # Message is sent AS the specified user - expect(created_message.chat_channel_id).to eq(chat_channel.id) - end - - it "can create a chat message using channel slug" do - chat_channel.update!(name: "My Test Channel", slug: "my-test-channel") - expect(chat_channel.slug).to eq("my-test-channel") - - script = <<~JS - function invoke(params) { - return discourse.createChatMessage({ - channel_name: params.channel_slug, // Using slug here - username: params.username, - message: params.message - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { - "channel_slug" => chat_channel.slug, - "username" => chat_user.username, - "message" => "Hello via slug!", - }, - llm: nil, - bot_user: bot_user, - ) - - result = runner.invoke - - expect(result["success"]).to eq(true), "Tool invocation failed: #{result["error"]}" - # see: https://github.com/rubyjs/mini_racer/issues/348 - # expect(result["message_id"]).to be_a(Integer) - - created_message = Chat::Message.find_by(id: result["message_id"]) - expect(created_message).not_to be_nil - expect(created_message.message).to eq("Hello via slug!") - expect(created_message.chat_channel_id).to eq(chat_channel.id) - end - - it "returns an error if the channel is not found" do - script = <<~JS - function invoke(params) { - return discourse.createChatMessage({ - channel_name: "non_existent_channel", - username: params.username, - message: params.message - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { "username" => chat_user.username, "message" => "Test" }, - llm: nil, - bot_user: bot_user, - ) - - initial_message_count = Chat::Message.count - expect { runner.invoke }.to raise_error( - MiniRacer::RuntimeError, - /Channel not found: non_existent_channel/, - ) - - expect(Chat::Message.count).to eq(initial_message_count) # Verify no message created - end - - it "returns an error if the user is not found" do - script = <<~JS - function invoke(params) { - return discourse.createChatMessage({ - channel_name: params.channel_name, - username: "non_existent_user", - message: params.message - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { "channel_name" => chat_channel.name, "message" => "Test" }, - llm: nil, - bot_user: bot_user, - ) - - initial_message_count = Chat::Message.count - expect { runner.invoke }.to raise_error( - MiniRacer::RuntimeError, - /User not found: non_existent_user/, - ) - - expect(Chat::Message.count).to eq(initial_message_count) # Verify no message created - end - end - - context "when updating personas" do - fab!(:ai_persona) do - Fabricate(:ai_persona, name: "TestPersona", system_prompt: "Original prompt") - end - - it "can update a persona with proper permissions" do - script = <<~JS - function invoke(params) { - return discourse.updatePersona(params.persona_name, { - system_prompt: params.new_prompt, - temperature: 0.7, - top_p: 0.9 - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { persona_name: "TestPersona", new_prompt: "Updated system prompt" }, - llm: nil, - bot_user: bot_user, - ) - - result = runner.invoke - expect(result["success"]).to eq(true) - expect(result["persona"]["system_prompt"]).to eq("Updated system prompt") - expect(result["persona"]["temperature"]).to eq(0.7) - - ai_persona.reload - expect(ai_persona.system_prompt).to eq("Updated system prompt") - expect(ai_persona.temperature).to eq(0.7) - expect(ai_persona.top_p).to eq(0.9) - end - end - - context "when fetching persona information" do - fab!(:ai_persona) do - Fabricate( - :ai_persona, - name: "TestPersona", - description: "Test description", - system_prompt: "Test system prompt", - temperature: 0.8, - top_p: 0.9, - vision_enabled: true, - tools: ["Search", ["WebSearch", { param: "value" }, true]], - ) - end - - it "can fetch a persona by name" do - script = <<~JS - function invoke(params) { - const persona = discourse.getPersona(params.persona_name); - return persona; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ persona_name: "TestPersona" }, llm: nil, bot_user: bot_user) - - result = runner.invoke - - expect(result["id"]).to eq(ai_persona.id) - expect(result["name"]).to eq("TestPersona") - expect(result["description"]).to eq("Test description") - expect(result["system_prompt"]).to eq("Test system prompt") - expect(result["temperature"]).to eq(0.8) - expect(result["top_p"]).to eq(0.9) - expect(result["vision_enabled"]).to eq(true) - expect(result["tools"]).to include("Search") - expect(result["tools"][1]).to be_a(Array) - end - - it "raises an error when the persona doesn't exist" do - script = <<~JS - function invoke(params) { - return discourse.getPersona("NonExistentPersona"); - } - JS - - tool = create_tool(script: script) - runner = tool.runner({}, llm: nil, bot_user: bot_user) - - expect { runner.invoke }.to raise_error(MiniRacer::RuntimeError, /Persona not found/) - end - - it "can update a persona after fetching it" do - script = <<~JS - function invoke(params) { - const persona = discourse.getPersona("TestPersona"); - return persona.update({ - system_prompt: "Updated through getPersona().update()", - temperature: 0.5 - }); - } - JS - - tool = create_tool(script: script) - runner = tool.runner({}, llm: nil, bot_user: bot_user) - - result = runner.invoke - expect(result["success"]).to eq(true) - - ai_persona.reload - expect(ai_persona.system_prompt).to eq("Updated through getPersona().update()") - expect(ai_persona.temperature).to eq(0.5) - end - end - - it "can use sleep function with limits" do - script = <<~JS - function invoke(params) { - let results = []; - for (let i = 0; i < 3; i++) { - let result = sleep(1); // 1ms sleep - results.push(result); - } - return results; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({}, llm: nil, bot_user: nil) - - result = runner.invoke - - expect(result).to eq([{ "slept" => 1 }, { "slept" => 1 }, { "slept" => 1 }]) - end - - let(:jpg) { plugin_file_from_fixtures("1x1.jpg") } - - describe "upload base64 encoding" do - it "can get base64 data from upload ID and short URL" do - upload = UploadCreator.new(jpg, "1x1.jpg").create_for(Discourse.system_user.id) - - # Test with upload ID - script_id = <<~JS - function invoke(params) { - return upload.getBase64(params.upload_id, params.max_pixels); - } - JS - - tool = create_tool(script: script_id) - runner = - tool.runner( - { "upload_id" => upload.id, "max_pixels" => 1_000_000 }, - llm: nil, - bot_user: nil, - ) - result_id = runner.invoke - - expect(result_id).to be_present - expect(result_id).to be_a(String) - expect(result_id.length).to be > 0 - - # Test with short URL - script_url = <<~JS - function invoke(params) { - return upload.getBase64(params.short_url, params.max_pixels); - } - JS - - tool = create_tool(script: script_url) - runner = - tool.runner( - { "short_url" => upload.short_url, "max_pixels" => 1_000_000 }, - llm: nil, - bot_user: nil, - ) - result_url = runner.invoke - - expect(result_url).to be_present - expect(result_url).to be_a(String) - expect(result_url).to eq(result_id) # Should return same base64 data - - # Test with invalid upload ID - script_invalid = <<~JS - function invoke(params) { - return upload.getBase64(99999); - } - JS - - tool = create_tool(script: script_invalid) - runner = tool.runner({}, llm: nil, bot_user: nil) - result_invalid = runner.invoke - - expect(result_invalid).to be_nil - end - end - - describe "upload URL resolution" do - it "can resolve upload short URLs to public URLs" do - upload = - Fabricate( - :upload, - sha1: "abcdef1234567890abcdef1234567890abcdef12", - url: "/uploads/default/original/1X/test.jpg", - original_filename: "test.jpg", - ) - - script = <<~JS - function invoke(params) { - return upload.getUrl(params.short_url); - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "short_url" => upload.short_url }, llm: nil, bot_user: nil) - - result = runner.invoke - - expect(result).to eq(GlobalPath.full_cdn_url(upload.url)) - end - - it "returns null for invalid upload short URLs" do - script = <<~JS - function invoke(params) { - return upload.getUrl(params.short_url); - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ "short_url" => "upload://invalid" }, llm: nil, bot_user: nil) - - result = runner.invoke - - expect(result).to be_nil - end - - it "returns null for non-existent uploads" do - script = <<~JS - function invoke(params) { - return upload.getUrl(params.short_url); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { "short_url" => "upload://hwmUkTAL9mwhQuRMLsXw6tvDi5C.jpeg" }, - llm: nil, - bot_user: nil, - ) - - result = runner.invoke - - expect(result).to be_nil - end - end - - context "when creating staged users" do - it "can create a staged user" do - script = <<~JS - function invoke(params) { - return discourse.createStagedUser({ - email: params.email, - username: params.username, - name: params.name - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { email: "testuser@example.com", username: "testuser123", name: "Test User" }, - llm: nil, - bot_user: nil, - ) - - result = runner.invoke - - expect(result["success"]).to eq(true) - expect(result["username"]).to eq("testuser123") - expect(result["email"]).to eq("testuser@example.com") - - user = User.find_by(id: result["user_id"]) - expect(user).not_to be_nil - expect(user.staged).to eq(true) - expect(user.username).to eq("testuser123") - expect(user.email).to eq("testuser@example.com") - expect(user.name).to eq("Test User") - end - - it "returns an error if user already exists" do - existing_user = Fabricate(:user, email: "existing@example.com", username: "existinguser") - - script = <<~JS - function invoke(params) { - try { - return discourse.createStagedUser({ - email: params.email, - username: params.username - }); - } catch (e) { - return { error: e.message }; - } - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { email: existing_user.email, username: "newusername" }, - llm: nil, - bot_user: nil, - ) - - result = runner.invoke - - expect(result["error"]).to eq("User already exists") - end - end - - context "when creating topics" do - fab!(:category) - fab!(:user) { Fabricate(:admin) } - - it "can create a topic" do - script = <<~JS - function invoke(params) { - return discourse.createTopic({ - category_id: params.category_id, - title: params.title, - raw: params.raw, - username: params.username, - tags: params.tags - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { - category_id: category.id, - title: "Test Topic Title", - raw: "This is the content of the test topic", - username: user.username, - tags: %w[test example], - }, - llm: nil, - bot_user: nil, - ) - - result = runner.invoke - - expect(result["success"]).to eq(true) - expect(result["topic_id"]).to be_present - expect(result["post_id"]).to be_present - - topic = Topic.find_by(id: result["topic_id"]) - expect(topic).not_to be_nil - expect(topic.title).to eq("Test Topic Title") - expect(topic.category_id).to eq(category.id) - expect(topic.user_id).to eq(user.id) - expect(topic.archetype).to eq("regular") - expect(topic.tags.pluck(:name)).to contain_exactly("test", "example") - - post = Post.find_by(id: result["post_id"]) - expect(post).not_to be_nil - expect(post.raw).to eq("This is the content of the test topic") - end - - it "can create a topic without username (uses system user)" do - script = <<~JS - function invoke(params) { - return discourse.createTopic({ - category_id: params.category_id, - title: params.title, - raw: params.raw - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { category_id: category.id, title: "System User Topic", raw: "Created by system" }, - llm: nil, - bot_user: nil, - ) - - result = runner.invoke - - expect(result["success"]).to eq(true) - - topic = Topic.find_by(id: result["topic_id"]) - expect(topic.user_id).to eq(Discourse.system_user.id) - end - - it "returns an error for invalid category" do - script = <<~JS - function invoke(params) { - return discourse.createTopic({ - category_id: 99999, - title: "Test", - raw: "Test" - }); - } - JS - - tool = create_tool(script: script) - runner = tool.runner({}, llm: nil, bot_user: nil) - - expect { runner.invoke }.to raise_error(MiniRacer::RuntimeError, /Category not found/) - end - end - - context "when creating posts" do - fab!(:topic) { Fabricate(:post).topic } - fab!(:user) - - it "can create a post in a topic" do - script = <<~JS - function invoke(params) { - return discourse.createPost({ - topic_id: params.topic_id, - raw: params.raw, - username: params.username - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { topic_id: topic.id, raw: "This is a reply to the topic", username: user.username }, - llm: nil, - bot_user: nil, - ) - - result = runner.invoke - - expect(result["success"]).to eq(true) - expect(result["post_id"]).to be_present - expect(result["post_number"]).to be > 1 - - post = Post.find_by(id: result["post_id"]) - expect(post).not_to be_nil - expect(post.raw).to eq("This is a reply to the topic") - expect(post.topic_id).to eq(topic.id) - expect(post.user_id).to eq(user.id) - end - - it "can create a reply to a specific post" do - _original_post = Fabricate(:post, topic: topic, post_number: 2) - - script = <<~JS - function invoke(params) { - return discourse.createPost({ - topic_id: params.topic_id, - raw: params.raw, - reply_to_post_number: params.reply_to_post_number - }); - } - JS - - tool = create_tool(script: script) - runner = - tool.runner( - { topic_id: topic.id, raw: "This is a reply to post #2", reply_to_post_number: 2 }, - llm: nil, - bot_user: nil, - ) - - result = runner.invoke - - expect(result["success"]).to eq(true) - - post = Post.find_by(id: result["post_id"]) - expect(post.reply_to_post_number).to eq(2) - end - - it "returns an error for invalid topic" do - script = <<~JS - function invoke(params) { - return discourse.createPost({ - topic_id: 99999, - raw: "Test" - }); - } - JS - - tool = create_tool(script: script) - runner = tool.runner({}, llm: nil, bot_user: nil) - - expect { runner.invoke }.to raise_error(MiniRacer::RuntimeError, /Topic not found/) - end - end - - context "when seeding a category with topics" do - fab!(:category) - - it "can seed a category with a topic and post" do - script = <<~JS - function invoke(params) { - // Create a staged user - const user = discourse.createStagedUser({ - email: 'testuser@example.com', - username: 'testuser', - name: 'Test User' - }); - - // Create a topic - const topic = discourse.createTopic({ - category_name: params.category_name, - title: 'Test Topic 123 123 123', - raw: 'This is the initial post content.', - username: user.username - }); - - // Add an extra post to the topic - const post = discourse.createPost({ - topic_id: topic.topic_id, - raw: 'This is a reply to the topic.', - username: user.username - }); - - return { - success: true, - user: user, - topic: topic, - post: post - }; - } - JS - - tool = create_tool(script: script) - runner = tool.runner({ category_name: category.name }, llm: nil, bot_user: nil) - - result = runner.invoke - - expect(result["success"]).to eq(true) - - user = User.find_by(username: "testuser") - expect(user).not_to be_nil - expect(user.staged).to eq(true) - - topic = Topic.find_by(id: result["topic"]["topic_id"]) - expect(topic).not_to be_nil - expect(topic.category_id).to eq(category.id) - - expect(topic.posts.count).to eq(2) - end - end -end diff --git a/spec/models/inferred_concept_spec.rb b/spec/models/inferred_concept_spec.rb deleted file mode 100644 index e74b8720..00000000 --- a/spec/models/inferred_concept_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe InferredConcept do - before { enable_current_plugin } - - describe "validations" do - it "requires a name" do - concept = InferredConcept.new - expect(concept).not_to be_valid - expect(concept.errors[:name]).to include("can't be blank") - end - - it "requires unique names" do - Fabricate(:inferred_concept, name: "ruby") - concept = InferredConcept.new(name: "ruby") - expect(concept).not_to be_valid - expect(concept.errors[:name]).to include("has already been taken") - end - - it "is valid with a unique name" do - concept = Fabricate(:inferred_concept, name: "programming") - expect(concept).to be_valid - end - end - - describe "associations" do - fab!(:topic) - fab!(:post) - fab!(:concept) { Fabricate(:inferred_concept, name: "programming") } - - it "can be associated with topics" do - concept.topics << topic - expect(concept.topics).to include(topic) - expect(topic.inferred_concepts).to include(concept) - end - - it "can be associated with posts" do - concept.posts << post - expect(concept.posts).to include(post) - expect(post.inferred_concepts).to include(concept) - end - - it "can have multiple topics and posts" do - topic2 = Fabricate(:topic) - post2 = Fabricate(:post) - - concept.topics << [topic, topic2] - concept.posts << [post, post2] - - expect(concept.topics.count).to eq(2) - expect(concept.posts.count).to eq(2) - end - end - - describe "database constraints" do - it "has the expected schema" do - concept = Fabricate(:inferred_concept) - expect(concept).to respond_to(:name) - expect(concept).to respond_to(:created_at) - expect(concept).to respond_to(:updated_at) - end - end -end diff --git a/spec/models/llm_model_spec.rb b/spec/models/llm_model_spec.rb deleted file mode 100644 index 9395bc50..00000000 --- a/spec/models/llm_model_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe LlmModel do - before { enable_current_plugin } - - describe "api_key" do - fab!(:llm_model) { Fabricate(:seeded_model) } - - before { ENV["DISCOURSE_AI_SEEDED_LLM_API_KEY_2"] = "blabla" } - - it "should use environment variable over database value if seeded LLM" do - expect(llm_model.api_key).to eq("blabla") - end - end -end diff --git a/spec/models/llm_quota_spec.rb b/spec/models/llm_quota_spec.rb deleted file mode 100644 index 1a6db357..00000000 --- a/spec/models/llm_quota_spec.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true -RSpec.describe LlmQuota do - fab!(:group) - fab!(:user) - fab!(:llm_model) - - before do - enable_current_plugin - group.add(user) - end - - describe ".check_quotas!" do - it "returns true when user is nil" do - expect(described_class.check_quotas!(llm_model, nil)).to be true - end - - it "returns true when no quotas exist for the user's groups" do - expect(described_class.check_quotas!(llm_model, user)).to be true - end - - it "raises no error when within quota" do - quota = Fabricate(:llm_quota, group: group, llm_model: llm_model) - _usage = - Fabricate( - :llm_quota_usage, - user: user, - llm_quota: quota, - input_tokens_used: quota.max_tokens - 100, - ) - - expect { described_class.check_quotas!(llm_model, user) }.not_to raise_error - end - - it "raises error when usage exceeds token limit" do - quota = Fabricate(:llm_quota, group: group, llm_model: llm_model, max_tokens: 1000) - _usage = Fabricate(:llm_quota_usage, user: user, llm_quota: quota, input_tokens_used: 1100) - - expect { described_class.check_quotas!(llm_model, user) }.to raise_error( - LlmQuotaUsage::QuotaExceededError, - ) - end - - it "raises error when usage exceeds usage limit" do - quota = Fabricate(:llm_quota, group: group, llm_model: llm_model, max_usages: 10) - _usage = Fabricate(:llm_quota_usage, user: user, llm_quota: quota, usages: 11) - - expect { described_class.check_quotas!(llm_model, user) }.to raise_error( - LlmQuotaUsage::QuotaExceededError, - ) - end - - it "checks all quotas from user's groups" do - group2 = Fabricate(:group) - group2.add(user) - - quota1 = Fabricate(:llm_quota, group: group, llm_model: llm_model, max_tokens: 1000) - quota2 = Fabricate(:llm_quota, group: group2, llm_model: llm_model, max_tokens: 500) - - described_class.log_usage(llm_model, user, 900, 0) # Should create usages for both quotas - - expect { described_class.check_quotas!(llm_model, user) }.not_to raise_error - - described_class.log_usage(llm_model, user, 101, 0) # This should push quota2 over its limit - - expect { described_class.check_quotas!(llm_model, user) }.to raise_error( - LlmQuotaUsage::QuotaExceededError, - ) - - # Verify the usage was logged for both quotas - expect(LlmQuotaUsage.find_by(llm_quota: quota1).total_tokens_used).to eq(1001) - expect(LlmQuotaUsage.find_by(llm_quota: quota2).total_tokens_used).to eq(1001) - end - end - - describe ".log_usage" do - it "does nothing when user is nil" do - expect { described_class.log_usage(llm_model, nil, 100, 50) }.not_to change( - LlmQuotaUsage, - :count, - ) - end - - it "creates usage records when none exist" do - _quota = Fabricate(:llm_quota, group: group, llm_model: llm_model) - - expect { described_class.log_usage(llm_model, user, 100, 50) }.to change( - LlmQuotaUsage, - :count, - ).by(1) - - usage = LlmQuotaUsage.last - expect(usage.input_tokens_used).to eq(100) - expect(usage.output_tokens_used).to eq(50) - expect(usage.usages).to eq(1) - end - - it "updates existing usage records" do - quota = Fabricate(:llm_quota, group: group, llm_model: llm_model) - usage = - Fabricate( - :llm_quota_usage, - user: user, - llm_quota: quota, - input_tokens_used: 100, - output_tokens_used: 50, - usages: 1, - ) - - described_class.log_usage(llm_model, user, 50, 25) - - usage.reload - expect(usage.input_tokens_used).to eq(150) - expect(usage.output_tokens_used).to eq(75) - expect(usage.usages).to eq(2) - end - - it "logs usage for all quotas from user's groups" do - group2 = Fabricate(:group) - group2.add(user) - - _quota1 = Fabricate(:llm_quota, group: group, llm_model: llm_model) - _quota2 = Fabricate(:llm_quota, group: group2, llm_model: llm_model) - - expect { described_class.log_usage(llm_model, user, 100, 50) }.to change( - LlmQuotaUsage, - :count, - ).by(2) - - expect(LlmQuotaUsage.where(user: user).count).to eq(2) - end - end -end diff --git a/spec/models/llm_quota_usage_spec.rb b/spec/models/llm_quota_usage_spec.rb deleted file mode 100644 index 2516c0cc..00000000 --- a/spec/models/llm_quota_usage_spec.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe LlmQuotaUsage do - fab!(:group) - fab!(:user) - fab!(:llm_model) - - fab!(:llm_quota) do - Fabricate( - :llm_quota, - group: group, - llm_model: llm_model, - max_tokens: 1000, - max_usages: 10, - duration_seconds: 1.day.to_i, - ) - end - - before { enable_current_plugin } - - describe ".find_or_create_for" do - it "creates a new usage record if none exists" do - freeze_time - - usage = described_class.find_or_create_for(user: user, llm_quota: llm_quota) - - expect(usage).to be_persisted - expect(usage.started_at).to eq_time(Time.current) - expect(usage.reset_at).to eq_time(Time.current + llm_quota.duration_seconds.seconds) - expect(usage.input_tokens_used).to eq(0) - expect(usage.output_tokens_used).to eq(0) - expect(usage.usages).to eq(0) - end - - it "returns existing usage record if one exists" do - existing = Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) - - usage = described_class.find_or_create_for(user: user, llm_quota: llm_quota) - - expect(usage.id).to eq(existing.id) - end - end - - describe "#reset_if_needed!" do - let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) } - - it "resets usage when past reset_at" do - usage.update!( - input_tokens_used: 100, - output_tokens_used: 200, - usages: 5, - reset_at: 1.minute.ago, - ) - - freeze_time - - usage.reset_if_needed! - - expect(usage.reload.input_tokens_used).to eq(0) - expect(usage.output_tokens_used).to eq(0) - expect(usage.usages).to eq(0) - expect(usage.started_at).to eq_time(Time.current) - expect(usage.reset_at).to eq_time(Time.current + llm_quota.duration_seconds.seconds) - end - - it "doesn't reset if reset_at hasn't passed" do - freeze_time - - original_values = { - input_tokens_used: 100, - output_tokens_used: 200, - usages: 5, - reset_at: 1.minute.from_now, - } - - usage.update!(original_values) - usage.reset_if_needed! - - usage.reload - expect(usage.input_tokens_used).to eq(original_values[:input_tokens_used]) - expect(usage.output_tokens_used).to eq(original_values[:output_tokens_used]) - expect(usage.usages).to eq(original_values[:usages]) - expect(usage.reset_at).to eq_time(original_values[:reset_at]) - end - end - - describe "#increment_usage!" do - let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) } - - it "increments usage counts" do - usage.increment_usage!(input_tokens: 50, output_tokens: 30) - - expect(usage.reload.input_tokens_used).to eq(50) - expect(usage.output_tokens_used).to eq(30) - expect(usage.usages).to eq(1) - end - - it "accumulates multiple increments" do - 2.times { usage.increment_usage!(input_tokens: 50, output_tokens: 30) } - - expect(usage.reload.input_tokens_used).to eq(100) - expect(usage.output_tokens_used).to eq(60) - expect(usage.usages).to eq(2) - end - - it "resets counts if needed before incrementing" do - usage.update!( - input_tokens_used: 100, - output_tokens_used: 200, - usages: 5, - reset_at: 1.minute.ago, - ) - - usage.increment_usage!(input_tokens: 50, output_tokens: 30) - - expect(usage.reload.input_tokens_used).to eq(50) - expect(usage.output_tokens_used).to eq(30) - expect(usage.usages).to eq(1) - end - end - - describe "#check_quota!" do - let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) } - - it "doesn't raise error when within limits" do - expect { usage.check_quota! }.not_to raise_error - end - - it "raises error when max_tokens exceeded" do - usage.update!(input_tokens_used: llm_quota.max_tokens + 1) - - expect { usage.check_quota! }.to raise_error(LlmQuotaUsage::QuotaExceededError, /exceeded/) - end - - it "raises error when max_usages exceeded" do - usage.update!(usages: llm_quota.max_usages + 1) - - expect { usage.check_quota! }.to raise_error(LlmQuotaUsage::QuotaExceededError, /exceeded/) - end - - it "resets quota if needed before checking" do - usage.update!(input_tokens_used: llm_quota.max_tokens + 1, reset_at: 1.minute.ago) - - expect { usage.check_quota! }.not_to raise_error - expect(usage.reload.input_tokens_used).to eq(0) - end - end - - describe "#quota_exceeded?" do - let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) } - - it "returns false when within limits" do - expect(usage.quota_exceeded?).to be false - end - - it "returns true when max_tokens exceeded" do - usage.update!(input_tokens_used: llm_quota.max_tokens + 1) - expect(usage.quota_exceeded?).to be true - end - - it "returns true when max_usages exceeded" do - usage.update!(usages: llm_quota.max_usages + 1) - expect(usage.quota_exceeded?).to be true - end - - it "returns false when quota is nil" do - tokens = llm_quota.max_tokens + 1 - usage.llm_quota.update!(max_tokens: nil) - usage.update!(input_tokens_used: tokens) - expect(usage.quota_exceeded?).to be false - end - end - - describe "calculation methods" do - let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) } - - describe "#total_tokens_used" do - it "sums input and output tokens" do - usage.update!(input_tokens_used: 100, output_tokens_used: 200) - expect(usage.total_tokens_used).to eq(300) - end - end - - describe "#remaining_tokens" do - it "calculates remaining tokens when under limit" do - usage.update!(input_tokens_used: 300, output_tokens_used: 200) - expect(usage.remaining_tokens).to eq(500) - end - - it "returns 0 when over limit" do - usage.update!(input_tokens_used: 800, output_tokens_used: 300) - expect(usage.remaining_tokens).to eq(0) - end - - it "returns nil when no max_tokens set" do - usage.llm_quota.update!(max_tokens: nil) - expect(usage.remaining_tokens).to be_nil - end - end - - describe "#remaining_usages" do - it "calculates remaining usages when under limit" do - usage.update!(usages: 7) - expect(usage.remaining_usages).to eq(3) - end - - it "returns 0 when over limit" do - usage.update!(usages: 15) - expect(usage.remaining_usages).to eq(0) - end - - it "returns nil when no max_usages set" do - usage.llm_quota.update!(max_usages: nil) - expect(usage.remaining_usages).to be_nil - end - end - - describe "#percentage_tokens_used" do - it "calculates percentage correctly" do - usage.update!(input_tokens_used: 250, output_tokens_used: 250) - expect(usage.percentage_tokens_used).to eq(50) - end - - it "caps at 100%" do - usage.update!(input_tokens_used: 2000) - expect(usage.percentage_tokens_used).to eq(100) - end - - it "returns 0 when no max_tokens set" do - usage.llm_quota.update!(max_tokens: nil) - expect(usage.percentage_tokens_used).to eq(0) - end - end - - describe "#percentage_usages_used" do - it "calculates percentage correctly" do - usage.update!(usages: 5) - expect(usage.percentage_usages_used).to eq(50) - end - - it "caps at 100%" do - usage.update!(usages: 20) - expect(usage.percentage_usages_used).to eq(100) - end - - it "returns 0 when no max_usages set" do - usage.llm_quota.update!(max_usages: nil) - expect(usage.percentage_usages_used).to eq(0) - end - end - end -end diff --git a/spec/models/model_accuracy_spec.rb b/spec/models/model_accuracy_spec.rb deleted file mode 100644 index f10083b9..00000000 --- a/spec/models/model_accuracy_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -describe ModelAccuracy do - before { enable_current_plugin } - - describe "#calculate_accuracy" do - let(:accuracy) { ModelAccuracy.new(model: "test_model", classification_type: "test") } - - it "returns 0 if we had no feedback" do - expect(accuracy.calculate_accuracy).to eq(0.0) - end - - it "returns 50 if we had mixed feedback" do - accuracy.flags_agreed = 1 - accuracy.flags_disagreed = 1 - - expect(accuracy.calculate_accuracy).to eq(50) - end - - it "always round the number" do - accuracy.flags_agreed = 1 - accuracy.flags_disagreed = 2 - - expect(accuracy.calculate_accuracy).to eq(33) - end - end - - describe ".adjust_model_accuracy" do - let!(:accuracy) { ModelAccuracy.create!(model: "test_model", classification_type: "test") } - - def build_reviewable(klass, test_model_verdict) - klass.new(payload: { "verdicts" => { "test_model" => test_model_verdict } }) - end - - it "does nothing if the reviewable is not generated by this plugin" do - reviewable = build_reviewable(ReviewableFlaggedPost, true) - - described_class.adjust_model_accuracy(:approved, reviewable) - - expect(accuracy.reload.flags_agreed).to be_zero - expect(accuracy.flags_disagreed).to be_zero - end - - it "updates the agreed flag if reviewable was approved and verdict is true" do - reviewable = build_reviewable(ReviewableAiPost, true) - - described_class.adjust_model_accuracy(:approved, reviewable) - - expect(accuracy.reload.flags_agreed).to eq(1) - expect(accuracy.flags_disagreed).to be_zero - end - - it "updates the disagreed flag if the reviewable was approved and verdict is false" do - reviewable = build_reviewable(ReviewableAiPost, false) - - described_class.adjust_model_accuracy(:approved, reviewable) - - expect(accuracy.reload.flags_agreed).to be_zero - expect(accuracy.flags_disagreed).to eq(1) - end - - it "updates the disagreed flag if reviewable was rejected and verdict is true" do - reviewable = build_reviewable(ReviewableAiPost, true) - - described_class.adjust_model_accuracy(:rejected, reviewable) - - expect(accuracy.reload.flags_agreed).to be_zero - expect(accuracy.flags_disagreed).to eq(1) - end - - it "updates the agreed flag if the reviewable was rejected and verdict is false" do - reviewable = build_reviewable(ReviewableAiPost, false) - - described_class.adjust_model_accuracy(:rejected, reviewable) - - expect(accuracy.reload.flags_agreed).to eq(1) - expect(accuracy.flags_disagreed).to be_zero - end - end -end diff --git a/spec/models/rag_document_fragment_spec.rb b/spec/models/rag_document_fragment_spec.rb deleted file mode 100644 index 52305e78..00000000 --- a/spec/models/rag_document_fragment_spec.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe RagDocumentFragment do - fab!(:persona) { Fabricate(:ai_persona) } - fab!(:upload_1) { Fabricate(:upload) } - fab!(:upload_2) { Fabricate(:upload) } - fab!(:vector_def) { Fabricate(:embedding_definition) } - - before do - enable_current_plugin - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_enabled = true - end - - describe ".link_uploads_and_persona" do - it "does nothing if there is no persona" do - expect { described_class.link_target_and_uploads(nil, [upload_1.id]) }.not_to change( - Jobs::DigestRagUpload.jobs, - :size, - ) - end - - it "does nothing if there are no uploads" do - expect { described_class.link_target_and_uploads(persona, []) }.not_to change( - Jobs::DigestRagUpload.jobs, - :size, - ) - end - - it "queues a job for each upload to generate fragments" do - expect { - described_class.link_target_and_uploads(persona, [upload_1.id, upload_2.id]) - }.to change(Jobs::DigestRagUpload.jobs, :size).by(2) - end - - it "creates references between the persona an each upload" do - described_class.link_target_and_uploads(persona, [upload_1.id, upload_2.id]) - - refs = UploadReference.where(target: persona).pluck(:upload_id) - - expect(refs).to contain_exactly(upload_1.id, upload_2.id) - end - end - - describe ".update_target_uploads" do - it "does nothing if there is no persona" do - expect { described_class.update_target_uploads(nil, [upload_1.id]) }.not_to change( - Jobs::DigestRagUpload.jobs, - :size, - ) - end - - it "deletes the fragment if its not present in the uploads list" do - fragment = Fabricate(:rag_document_fragment, target: persona) - - described_class.update_target_uploads(persona, []) - - expect { fragment.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "delete references between the upload and the persona" do - described_class.link_target_and_uploads(persona, [upload_1.id, upload_2.id]) - described_class.update_target_uploads(persona, [upload_2.id]) - - refs = UploadReference.where(target: persona).pluck(:upload_id) - - expect(refs).to contain_exactly(upload_2.id) - end - - it "queues jobs to generate new fragments" do - expect { described_class.update_target_uploads(persona, [upload_1.id]) }.to change( - Jobs::DigestRagUpload.jobs, - :size, - ).by(1) - end - end - - describe ".indexing_status" do - let(:vector) { DiscourseAi::Embeddings::Vector.instance } - - let(:rag_document_fragment_1) do - Fabricate(:rag_document_fragment, upload: upload_1, target: persona) - end - - let(:rag_document_fragment_2) do - Fabricate(:rag_document_fragment, upload: upload_1, target: persona) - end - - let(:expected_embedding) { [0.0038493] * vector_def.dimensions } - - before do - SiteSetting.ai_embeddings_selected_model = vector_def.id - rag_document_fragment_1 - rag_document_fragment_2 - - WebMock.stub_request(:post, "https://test.com/embeddings").to_return( - status: 200, - body: JSON.dump(expected_embedding), - ) - - vector.generate_representation_from(rag_document_fragment_1) - end - - it "regenerates all embeddings if ai_embeddings_selected_model changes" do - old_id = rag_document_fragment_1.id - - UploadReference.create!(upload_id: upload_1.id, target: persona) - UploadReference.create!(upload_id: upload_2.id, target: persona) - - Sidekiq::Testing.fake! do - SiteSetting.ai_embeddings_selected_model = Fabricate(:open_ai_embedding_def).id - expect(RagDocumentFragment.exists?(old_id)).to eq(false) - expect(Jobs::DigestRagUpload.jobs.size).to eq(2) - end - end - - it "returns total, indexed and unindexed fragments for each upload" do - results = described_class.indexing_status(persona, [upload_1, upload_2]) - - upload_1_status = results[upload_1.id] - expect(upload_1_status[:total]).to eq(2) - expect(upload_1_status[:indexed]).to eq(1) - expect(upload_1_status[:left]).to eq(1) - - upload_1_status = results[upload_2.id] - expect(upload_1_status[:total]).to eq(0) - expect(upload_1_status[:indexed]).to eq(0) - expect(upload_1_status[:left]).to eq(0) - end - end -end diff --git a/spec/models/reviewable_ai_chat_message_spec.rb b/spec/models/reviewable_ai_chat_message_spec.rb deleted file mode 100644 index 57e38f1e..00000000 --- a/spec/models/reviewable_ai_chat_message_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe ReviewableAiChatMessage, type: :model do - fab!(:moderator) - fab!(:user) - fab!(:chat_channel) - fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } - fab!(:reviewable) { described_class.needs_review!(target: chat_message, created_by: moderator) } - - before { enable_current_plugin } - - it "agree_and_keep agrees with the flag and doesn't delete the message" do - reviewable.perform(moderator, :agree_and_keep_message) - - expect(reviewable).to be_approved - expect(chat_message.reload.deleted_at).not_to be_present - end - - it "agree_and_delete agrees with the flag and deletes the message" do - chat_message_id = chat_message.id - reviewable.perform(moderator, :agree_and_delete) - - expect(reviewable).to be_approved - expect(Chat::Message.with_deleted.find_by(id: chat_message_id).deleted_at).to be_present - end - - it "agree_and_restore agrees with the flag and restores the message" do - chat_message.trash!(user) - reviewable.perform(moderator, :agree_and_restore) - - expect(reviewable).to be_approved - expect(chat_message.reload.deleted_at).to be_nil - end - - it "perform_disagree disagrees with the flag and does nothing" do - reviewable.perform(moderator, :disagree) - - expect(reviewable).to be_rejected - end - - it "perform_disagree_and_restore disagrees with the flag and restores the message" do - chat_message.trash!(user) - reviewable.perform(moderator, :disagree_and_restore) - - expect(reviewable).to be_rejected - expect(chat_message.reload.deleted_at).to be_nil - end - - it "perform_ignore ignores the flag and does nothing" do - reviewable.perform(moderator, :ignore) - - expect(reviewable).to be_ignored - expect(chat_message.reload.deleted_at).not_to be_present - end - - context "when the flagged message author is silenced" do - before do - UserSilencer.silence( - user, - Discourse.system_user, - silenced_till: 10.minutes.from_now, - reason: I18n.t("chat.errors.auto_silence_from_flags"), - ) - end - - it "perform_disagree unsilences the user" do - reviewable.perform(moderator, :disagree) - - expect(user.reload.silenced?).to eq(false) - end - - it "perform_disagree_and_restore unsilences the user" do - chat_message.trash!(user) - reviewable.perform(moderator, :disagree_and_restore) - - expect(user.reload.silenced?).to eq(false) - end - end -end diff --git a/spec/models/reviewable_ai_post_spec.rb b/spec/models/reviewable_ai_post_spec.rb deleted file mode 100644 index 659876de..00000000 --- a/spec/models/reviewable_ai_post_spec.rb +++ /dev/null @@ -1,243 +0,0 @@ -# frozen_string_literal: true - -describe ReviewableAiPost do - fab!(:target) { Fabricate(:post) } - - before { enable_current_plugin } - - describe "#build_actions" do - let(:guardian) { Guardian.new } - - let(:reviewable) do - subject.tap do |r| - r.target = target - r.target_created_by = target.user - r.created_by = Discourse.system_user - end - end - - def reviewable_actions(a_guardian) - actions = Reviewable::Actions.new(reviewable, a_guardian, {}) - reviewable.build_actions(actions, a_guardian, {}) - - actions - end - - context "when the reviewable isn't pending" do - before { reviewable.status = Reviewable.statuses[:rejected] } - - it "returns no actions" do - expect(reviewable_actions(guardian)).to be_blank - end - end - - describe "actions that don't require special permissions" do - it "has the disagree action" do - expect(reviewable_actions(guardian).has?(:disagree)).to eq(true) - end - - it "has the ignore action" do - expect(reviewable_actions(guardian).has?(:ignore)).to eq(true) - end - - it "has the agree and hide or agree and keep actions" do - actions = reviewable_actions(guardian) - - expect(actions.has?(:agree_and_hide)).to eq(true) - expect(actions.has?(:agree_and_keep)).to eq(true) - expect(actions.has?(:agree_and_keep_hidden)).to eq(false) - end - - it "doesn't have the penalize actions" do - actions = reviewable_actions(guardian) - - expect(actions.has?(:agree_and_suspend)).to eq(false) - expect(actions.has?(:agree_and_silence)).to eq(false) - end - - it "doesn't has the delete + replies actions" do - actions = reviewable_actions(guardian) - - expect(actions.has?(:delete_and_ignore_replies)).to eq(false) - expect(actions.has?(:delete_and_agree_replies)).to eq(false) - end - - context "when the post is hidden" do - before { target.hide!(PostActionType.types[:inappropriate]) } - - it "can agree and keep hidden" do - actions = reviewable_actions(guardian) - - expect(actions.has?(:agree_and_hide)).to eq(false) - expect(actions.has?(:agree_and_keep)).to eq(false) - expect(actions.has?(:agree_and_keep_hidden)).to eq(true) - end - - it "has the disagree and restore action" do - actions = reviewable_actions(guardian) - - expect(actions.has?(:disagree)).to eq(false) - expect(actions.has?(:disagree_and_restore)).to eq(true) - end - end - - context "when the post was deleted by the user" do - before { target.user_deleted = true } - - it "lets you restore it but not hiding it" do - actions = reviewable_actions(guardian) - - expect(actions.has?(:agree_and_restore)).to eq(true) - expect(actions.has?(:agree_and_keep)).to eq(true) - expect(actions.has?(:agree_and_keep_hidden)).to eq(false) - expect(actions.has?(:agree_and_hide)).to eq(false) - end - end - end - - context "when the reviewer can suspend the poster" do - let(:mod_guardian) { Guardian.new(Fabricate(:moderator)) } - - it "has the penalization actions" do - actions = reviewable_actions(mod_guardian) - - expect(actions.has?(:agree_and_suspend)).to eq(true) - expect(actions.has?(:agree_and_silence)).to eq(true) - end - end - - context "when the reviewer can delete the post and topic" do - let(:mod_guardian) { Guardian.new(Fabricate(:moderator)) } - - it "has the delete + replies actions" do - target.reply_count = 3 - actions = reviewable_actions(mod_guardian) - - expect(actions.has?(:delete_and_ignore_replies)).to eq(true) - expect(actions.has?(:delete_and_agree_replies)).to eq(true) - end - end - end - - describe "#perform" do - let(:reviewable) do - described_class.needs_review!(target: target, created_by: Discourse.system_user) - end - fab!(:admin) - - before do - reviewable.add_score( - Discourse.system_user, - ReviewableScore.types[:inappropriate], - created_at: reviewable.created_at, - ) - end - - describe "agree variations" do - it "hides the topic when performing the agree_and_hide action" do - result = reviewable.perform(admin, :agree_and_hide) - - expect(result.transition_to).to eq :approved - expect(target.reload.hidden?).to eq(true) - end - - it "doesn't unhide the topic when performing the agree_and_keep_hidden action" do - target.hide!(ReviewableScore.types[:inappropriate]) - - result = reviewable.perform(admin, :agree_and_keep_hidden) - - expect(result.transition_to).to eq :approved - expect(target.reload.hidden?).to eq(true) - end - - it "un-deletes the post when performing the agree_and_restore action" do - target.update!(deleted_at: 1.minute.ago, deleted_by: target.user, user_deleted: true) - - result = reviewable.perform(admin, :agree_and_restore) - - expect(result.transition_to).to eq :approved - expect(target.reload.deleted_at).to be_nil - expect(target.user_deleted).to eq(false) - end - end - - describe "disagree variations" do - it "disagree_and_restore disagrees with the flag and unhides the post" do - target.hide!(ReviewableScore.types[:inappropriate]) - - result = reviewable.perform(admin, :disagree_and_restore) - - expect(result.transition_to).to eq :rejected - expect(target.reload.hidden?).to eq(false) - end - - it "disagree disagrees with the flag" do - result = reviewable.perform(admin, :disagree) - - expect(result.transition_to).to eq :rejected - end - end - - describe "delete post variations" do - def create_reply(post) - PostCreator.create( - Fabricate(:user), - raw: "this is the reply text", - reply_to_post_number: post.post_number, - topic_id: post.topic, - ) - end - - before { target.update!(reply_count: 1) } - - it "ignores the reviewable with delete_and_ignore" do - result = reviewable.perform(admin, :delete_and_ignore) - - expect(result.transition_to).to eq :ignored - expect(target.reload.deleted_at).to be_present - end - - it "ignores the reviewable and replies with delete_and_ignore_replies" do - reply = create_reply(target) - - result = reviewable.perform(admin, :delete_and_ignore_replies) - - expect(result.transition_to).to eq :ignored - expect(target.reload.deleted_at).to be_present - expect(reply.reload.deleted_at).to be_present - end - - it "agrees with the reviewable with delete_and_agree" do - result = reviewable.perform(admin, :delete_and_agree) - - expect(result.transition_to).to eq :approved - expect(target.reload.deleted_at).to be_present - end - - it "agrees with the reviewables and its replies with delete_and_agree_replies" do - reply = create_reply(target) - - result = reviewable.perform(admin, :delete_and_agree_replies) - - expect(result.transition_to).to eq :approved - expect(target.reload.deleted_at).to be_present - expect(reply.reload.deleted_at).to be_present - end - end - - describe "delete user variations" do - it "deletes the user and agrees with the reviewable" do - result = reviewable.perform(admin, :delete_user) - - expect(result.transition_to).to eq :approved - expect { target.user.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - end - - it "ignores the reviewable" do - result = reviewable.perform(admin, :ignore) - - expect(result.transition_to).to eq :ignored - end - end -end diff --git a/spec/models/shared_ai_conversation_spec.rb b/spec/models/shared_ai_conversation_spec.rb deleted file mode 100644 index 996790d5..00000000 --- a/spec/models/shared_ai_conversation_spec.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe SharedAiConversation, type: :model do - fab!(:claude_2) { Fabricate(:llm_model, name: "claude-2") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [claude_2]) - end - - fab!(:user) - - let(:bad_user_input) { <<~HTML } - Just trying something `h4cked` - HTML - let(:raw_with_details) { <<~HTML } -
    - GitHub pull request diff -

    discourse/discourse-ai 521

    -
    -

    This is some other text

    - HTML - - let(:bot_user) { claude_2.reload.user } - let!(:topic) { Fabricate(:private_message_topic, recipient: bot_user) } - let!(:post1) { Fabricate(:post, topic: topic, post_number: 1, raw: bad_user_input) } - let!(:post2) { Fabricate(:post, topic: topic, post_number: 2, raw: raw_with_details) } - - describe ".share_conversation" do - it "creates a new conversation if one does not exist" do - expect { described_class.share_conversation(user, topic) }.to change { - described_class.count - }.by(1) - end - - it "generates a good onebox" do - conversation = described_class.share_conversation(user, topic) - onebox = conversation.onebox - expect(onebox).not_to include("GitHub pull request diff") - expect(onebox).not_to include("
    ") - - expect(onebox).to include("AI Conversation with Claude-2") - end - - it "updates an existing conversation if one exists" do - conversation = described_class.share_conversation(user, topic) - expect(conversation.share_key).to be_present - - topic.update!(title: "New title") - - expect { described_class.share_conversation(user, topic) }.to_not change { - described_class.count - } - expect(conversation.reload.title).to eq("New title") - expect(conversation.share_key).to be_present - end - - it "includes the correct conversation data" do - conversation = described_class.share_conversation(user, topic) - expect(conversation.llm_name).to eq("Claude-2") - expect(conversation.title).to eq(topic.title) - expect(conversation.context.size).to eq(2) - expect(conversation.context[0]["id"]).to eq(post1.id) - expect(conversation.context[1]["id"]).to eq(post2.id) - - populated_context = conversation.populated_context - - expect(populated_context[0].id).to eq(post1.id) - expect(populated_context[0].user.id).to eq(post1.user.id) - expect(populated_context[1].id).to eq(post2.id) - expect(populated_context[1].user.id).to eq(post2.user.id) - end - - it "shares artifacts publicly when conversation is shared" do - # Create a post with an AI artifact - artifact = - Fabricate( - :ai_artifact, - post: post1, - user: user, - metadata: { - public: false, - something: "good", - }, - ) - - _post_with_artifact = - Fabricate( - :post, - topic: topic, - post_number: 3, - raw: "Here's an artifact", - cooked: - "
    ", - ) - - expect(artifact.public?).to be_falsey - - conversation = described_class.share_conversation(user, topic) - artifact.reload - - expect(artifact.metadata["something"]).to eq("good") - expect(artifact.public?).to be_truthy - - described_class.destroy_conversation(conversation) - artifact.reload - - expect(artifact.metadata["something"]).to eq("good") - expect(artifact.public?).to be_falsey - end - - it "escapes HTML" do - conversation = described_class.share_conversation(user, topic) - onebox = conversation.onebox - expect(onebox).not_to include("") - expect(onebox).to include("AI Conversation with Claude-2") - end - end -end diff --git a/spec/models/user_option_spec.rb b/spec/models/user_option_spec.rb deleted file mode 100644 index ab1c966d..00000000 --- a/spec/models/user_option_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe UserOption do - fab!(:user) - fab!(:llm_model) - fab!(:group) - fab!(:ai_persona) do - Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: llm_model.id) - end - - before do - enable_current_plugin - - assign_fake_provider_to(:ai_helper_model) - assign_fake_provider_to(:ai_helper_image_caption_model) - SiteSetting.ai_helper_enabled = true - SiteSetting.ai_helper_enabled_features = "image_caption" - SiteSetting.ai_auto_image_caption_allowed_groups = "10" # tl0 - - SiteSetting.ai_bot_enabled = true - end - - describe "#auto_image_caption" do - it "is present" do - expect(described_class.new.auto_image_caption).to eq(false) - end - end - - describe "#ai_search_discoveries" do - before do - SiteSetting.ai_bot_discover_persona = ai_persona.id - group.add(user) - end - - it "is present" do - expect(described_class.new.ai_search_discoveries).to eq(true) - end - end -end diff --git a/spec/plugin_helper.rb b/spec/plugin_helper.rb deleted file mode 100644 index 0346df7c..00000000 --- a/spec/plugin_helper.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module DiscourseAi::ChatBotHelper - def toggle_enabled_bots(bots: []) - models = LlmModel.all - models = models.where("id not in (?)", bots.map(&:id)) if bots.present? - models.update_all(enabled_chat_bot: false) - - bots.each { |b| b.update!(enabled_chat_bot: true) } - DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots - end - - def assign_fake_provider_to(setting_name) - Fabricate(:fake_model).tap do |fake_llm| - SiteSetting.public_send("#{setting_name}=", "custom:#{fake_llm.id}") - end - end - - def assign_persona_to(setting_name, allowed_group_ids) - Fabricate(:ai_persona, allowed_group_ids: allowed_group_ids).tap do |p| - SiteSetting.public_send("#{setting_name}=", p.id) - end - end -end - -RSpec.configure { |config| config.include DiscourseAi::ChatBotHelper } diff --git a/spec/plugin_spec.rb b/spec/plugin_spec.rb deleted file mode 100644 index 85052c52..00000000 --- a/spec/plugin_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -describe Plugin::Instance do - before { enable_current_plugin } - - describe "current_user_serializer#ai_helper_prompts" do - fab!(:user) - - before do - assign_fake_provider_to(:ai_helper_model) - SiteSetting.ai_helper_enabled = true - SiteSetting.ai_helper_illustrate_post_model = "disabled" - Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) - Group.refresh_automatic_groups! - - DiscourseAi::AiHelper::Assistant.clear_prompt_cache! - end - - let(:serializer) { CurrentUserSerializer.new(user, scope: Guardian.new(user)) } - - it "returns the available prompts" do - expect(serializer.ai_helper_prompts).to be_present - - expect(serializer.ai_helper_prompts.object.map { |p| p[:name] }).to contain_exactly( - "translate", - "generate_titles", - "proofread", - "markdown_table", - "explain", - "replace_dates", - ) - end - end -end diff --git a/spec/reports/sentiment_analysis_spec.rb b/spec/reports/sentiment_analysis_spec.rb deleted file mode 100644 index d0b28216..00000000 --- a/spec/reports/sentiment_analysis_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Sentiment::SentimentAnalysisReport do - fab!(:admin) - fab!(:category) - fab!(:topic) { Fabricate(:topic, category: category) } - fab!(:post) { Fabricate(:post, user: admin, topic: topic) } - fab!(:post_2) { Fabricate(:post, user: admin, topic: topic) } - fab!(:classification_result) { Fabricate(:classification_result, target: post) } - - before do - enable_current_plugin - SiteSetting.ai_sentiment_enabled = true - end - - it "contains the correct filters" do - report = Report.find("sentiment_analysis") - expect(report.available_filters).to include("group_by", "sort_by", "category", "tag") - end - - it "contains the correct labels" do - report = Report.find("sentiment_analysis") - expect(report.labels).to eq(%w[Positive Neutral Negative]) - end -end diff --git a/spec/requests/admin/ai_embeddings_controller_spec.rb b/spec/requests/admin/ai_embeddings_controller_spec.rb deleted file mode 100644 index 5c86ccd0..00000000 --- a/spec/requests/admin/ai_embeddings_controller_spec.rb +++ /dev/null @@ -1,262 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Admin::AiEmbeddingsController do - fab!(:admin) - - let(:valid_attrs) do - { - display_name: "Embedding config test", - dimensions: 1001, - max_sequence_length: 234, - pg_function: "<#>", - provider: "hugging_face", - url: "https://test.com/api/v1/embeddings", - api_key: "test", - tokenizer_class: "DiscourseAi::Tokenizer::BgeM3Tokenizer", - embed_prompt: "I come first:", - search_prompt: "prefix for search", - matryoshka_dimensions: true, - } - end - - before do - enable_current_plugin - sign_in(admin) - end - - describe "POST #create" do - context "with valid attrs" do - it "creates a new embedding definition" do - post "/admin/plugins/discourse-ai/ai-embeddings.json", params: { ai_embedding: valid_attrs } - - created_def = EmbeddingDefinition.last - - expect(response.status).to eq(201) - expect(created_def.display_name).to eq(valid_attrs[:display_name]) - expect(created_def.embed_prompt).to eq(valid_attrs[:embed_prompt]) - expect(created_def.search_prompt).to eq(valid_attrs[:search_prompt]) - expect(created_def.matryoshka_dimensions).to eq(true) - end - - it "logs the creation with StaffActionLogger" do - expect { - post "/admin/plugins/discourse-ai/ai-embeddings.json", - params: { - ai_embedding: valid_attrs, - } - }.to change { - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "create_ai_embedding", - ).count - }.by(1) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "create_ai_embedding", - ).last - expect(history.details).to include("display_name: Embedding config test") - expect(history.details).to include("provider: hugging_face") - expect(history.details).to include("dimensions: 1001") - expect(history.subject).to eq("Embedding config test") # Verify subject field is included - end - - it "stores provider-specific config params" do - post "/admin/plugins/discourse-ai/ai-embeddings.json", - params: { - ai_embedding: - valid_attrs.merge( - provider: "open_ai", - provider_params: { - model_name: "embeddings-v1", - }, - ), - } - - created_def = EmbeddingDefinition.last - - expect(response.status).to eq(201) - expect(created_def.provider_params["model_name"]).to eq("embeddings-v1") - end - - it "ignores parameters not associated with that provider" do - post "/admin/plugins/discourse-ai/ai-embeddings.json", - params: { - ai_embedding: valid_attrs.merge(provider_params: { custom: "custom" }), - } - - created_def = EmbeddingDefinition.last - - expect(response.status).to eq(201) - expect(created_def.lookup_custom_param("custom")).to be_nil - end - end - - context "with invalid attrs" do - it "doesn't create a new embedding defitinion" do - post "/admin/plugins/discourse-ai/ai-embeddings.json", - params: { - ai_embedding: valid_attrs.except(:provider), - } - - created_def = EmbeddingDefinition.last - - expect(created_def).to be_nil - end - end - end - - describe "PUT #update" do - fab!(:embedding_definition) - - context "with valid update params" do - let(:update_attrs) { { provider: "open_ai" } } - - it "updates the model" do - put "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json", - params: { - ai_embedding: update_attrs, - } - - expect(response.status).to eq(200) - expect(embedding_definition.reload.provider).to eq(update_attrs[:provider]) - end - - it "logs the update with StaffActionLogger" do - expect { - put "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json", - params: { - ai_embedding: update_attrs, - } - }.to change { - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_embedding", - ).count - }.by(1) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_embedding", - ).last - expect(history.details).to include("embedding_id: #{embedding_definition.id}") - expect(history.subject).to eq(embedding_definition.display_name) # Verify subject field is included - end - - it "returns a 404 if there is no model with the given Id" do - put "/admin/plugins/discourse-ai/ai-embeddings/9999999.json" - - expect(response.status).to eq(404) - end - - it "doesn't allow dimenstions to be updated" do - new_dimensions = 200 - - put "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json", - params: { - ai_embedding: { - dimensions: new_dimensions, - }, - } - - expect(response.status).to eq(200) - expect(embedding_definition.reload.dimensions).not_to eq(new_dimensions) - end - end - - context "with invalid update params" do - it "doesn't update the model" do - put "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json", - params: { - ai_embedding: { - url: "", - }, - } - - expect(response.status).to eq(422) - end - end - end - - describe "DELETE #destroy" do - fab!(:embedding_definition) - - it "destroys the embedding defitinion" do - expect { - delete "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json" - - expect(response).to have_http_status(:no_content) - }.to change(EmbeddingDefinition, :count).by(-1) - end - - it "logs the deletion with StaffActionLogger" do - embedding_id = embedding_definition.id - display_name = embedding_definition.display_name - - expect { - delete "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json" - }.to change { - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "delete_ai_embedding", - ).count - }.by(1) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "delete_ai_embedding", - ).last - expect(history.details).to include("embedding_id: #{embedding_id}") - expect(history.details).to include("display_name: #{display_name}") - expect(history.subject).to eq(display_name) # Verify subject field is included - end - - it "validates the model is not in use" do - SiteSetting.ai_embeddings_selected_model = embedding_definition.id - - delete "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json" - - expect(response.status).to eq(409) - expect(embedding_definition.reload).to eq(embedding_definition) - end - end - - describe "GET #test" do - context "when we can generate an embedding" do - it "returns a success true flag" do - WebMock.stub_request(:post, valid_attrs[:url]).to_return(status: 200, body: [[1]].to_json) - - get "/admin/plugins/discourse-ai/ai-embeddings/test.json", - params: { - ai_embedding: valid_attrs, - } - - expect(response).to be_successful - expect(response.parsed_body["success"]).to eq(true) - end - end - - context "when we cannot generate an embedding" do - it "returns a success false flag and the error message" do - error_message = { error: "Embedding generation failed." } - - WebMock.stub_request(:post, valid_attrs[:url]).to_return( - status: 422, - body: error_message.to_json, - ) - - get "/admin/plugins/discourse-ai/ai-embeddings/test.json", - params: { - ai_embedding: valid_attrs, - } - - expect(response).to be_successful - expect(response.parsed_body["success"]).to eq(false) - expect(response.parsed_body["error"]).to eq(error_message.to_json) - end - end - end -end diff --git a/spec/requests/admin/ai_features_controller_spec.rb b/spec/requests/admin/ai_features_controller_spec.rb deleted file mode 100644 index f2e96a38..00000000 --- a/spec/requests/admin/ai_features_controller_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Admin::AiFeaturesController do - let(:controller) { described_class.new } - fab!(:admin) - fab!(:group) - fab!(:llm_model) - fab!(:summarizer_persona) { Fabricate(:ai_persona) } - fab!(:alternate_summarizer_persona) { Fabricate(:ai_persona) } - - before do - enable_current_plugin - sign_in(admin) - SiteSetting.ai_bot_enabled = true - end - - describe "#index" do - it "lists all features backed by personas" do - get "/admin/plugins/discourse-ai/ai-features.json" - - expect(response.status).to eq(200) - expect(response.parsed_body["ai_features"].count).to eq(9) - end - end - - describe "#edit" do - it "returns a success response" do - get "/admin/plugins/discourse-ai/ai-features/1/edit.json" - expect(response.parsed_body["module_name"]).to eq("summarization") - end - end -end diff --git a/spec/requests/admin/ai_llm_quotas_controller_spec.rb b/spec/requests/admin/ai_llm_quotas_controller_spec.rb deleted file mode 100644 index ec7691d9..00000000 --- a/spec/requests/admin/ai_llm_quotas_controller_spec.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Admin::AiLlmQuotasController do - fab!(:admin) - fab!(:group) - fab!(:llm_model) - - before do - enable_current_plugin - sign_in(admin) - SiteSetting.ai_bot_enabled = true - end - - describe "#index" do - fab!(:quota) { Fabricate(:llm_quota, llm_model: llm_model, group: group) } - fab!(:quota2) { Fabricate(:llm_quota, llm_model: llm_model) } - - it "lists all quotas for a given LLM" do - get "/admin/plugins/discourse-ai/quotas.json" - - expect(response.status).to eq(200) - - quotas = response.parsed_body["quotas"] - expect(quotas.length).to eq(2) - expect(quotas.map { |q| q["id"] }).to contain_exactly(quota.id, quota2.id) - end - end - - describe "#create" do - let(:valid_params) do - { - quota: { - group_id: group.id, - llm_model_id: llm_model.id, - max_tokens: 1000, - max_usages: 100, - duration_seconds: 1.day.to_i, - }, - } - end - - it "creates a new quota with valid params" do - expect { - post "/admin/plugins/discourse-ai/quotas.json", params: valid_params - expect(response.status).to eq(201) - }.to change(LlmQuota, :count).by(1) - - quota = LlmQuota.last - expect(quota.group_id).to eq(group.id) - expect(quota.max_tokens).to eq(1000) - end - - it "fails with invalid params" do - post "/admin/plugins/discourse-ai/quotas.json", - params: { - quota: valid_params[:quota].except(:group_id), - } - - expect(response.status).to eq(422) - expect(LlmQuota.count).to eq(0) - end - end - - describe "#update" do - fab!(:quota) { Fabricate(:llm_quota, llm_model: llm_model, group: group) } - - it "updates quota with valid params" do - put "/admin/plugins/discourse-ai/quotas/#{quota.id}.json", - params: { - quota: { - max_tokens: 2000, - }, - } - - expect(response.status).to eq(200) - expect(quota.reload.max_tokens).to eq(2000) - end - - it "fails with invalid params" do - put "/admin/plugins/discourse-ai/quotas/#{quota.id}.json", - params: { - quota: { - duration_seconds: 0, - }, - } - - expect(response.status).to eq(422) - expect(quota.reload.duration_seconds).not_to eq(0) - end - end - - describe "#destroy" do - fab!(:quota) { Fabricate(:llm_quota, llm_model: llm_model, group: group) } - - it "deletes the quota" do - delete "/admin/plugins/discourse-ai/quotas/#{quota.id}.json" - - expect(response.status).to eq(204) - expect { quota.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "returns 404 for non-existent quota" do - delete "/admin/plugins/discourse-ai/quotas/9999.json" - - expect(response.status).to eq(404) - end - end -end diff --git a/spec/requests/admin/ai_llms_controller_spec.rb b/spec/requests/admin/ai_llms_controller_spec.rb deleted file mode 100644 index f62abdec..00000000 --- a/spec/requests/admin/ai_llms_controller_spec.rb +++ /dev/null @@ -1,538 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Admin::AiLlmsController do - fab!(:admin) - - before do - enable_current_plugin - sign_in(admin) - SiteSetting.ai_bot_enabled = true - end - - describe "GET #index" do - fab!(:llm_model) { Fabricate(:llm_model, enabled_chat_bot: true) } - fab!(:llm_model2) { Fabricate(:llm_model) } - fab!(:ai_persona) do - Fabricate( - :ai_persona, - name: "Cool persona", - force_default_llm: true, - default_llm_id: llm_model2.id, - ) - end - - fab!(:group) - fab!(:quota) { Fabricate(:llm_quota, llm_model: llm_model, group: group) } - fab!(:quota2) { Fabricate(:llm_quota, llm_model: llm_model, group: Fabricate(:group)) } - - it "includes quotas in serialized response" do - get "/admin/plugins/discourse-ai/ai-llms.json" - - expect(response.status).to eq(200) - - llms = response.parsed_body["ai_llms"] - expect(llms.length).to eq(2) - - model = llms.find { |m| m["id"] == llm_model.id } - expect(model["llm_quotas"]).to be_present - expect(model["llm_quotas"].length).to eq(2) - expect(model["llm_quotas"].map { |q| q["id"] }).to contain_exactly(quota.id, quota2.id) - end - - it "includes all available providers metadata" do - get "/admin/plugins/discourse-ai/ai-llms.json" - expect(response).to be_successful - - expect(response.parsed_body["meta"]["providers"]).to contain_exactly( - *DiscourseAi::Completions::Llm.provider_names, - ) - end - - it "lists enabled features on appropriate LLMs" do - SiteSetting.ai_bot_enabled = true - - # setting the setting calls the model - DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do - SiteSetting.ai_helper_model = "custom:#{llm_model.id}" - SiteSetting.ai_helper_enabled = true - end - - DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do - SiteSetting.ai_summarization_model = "custom:#{llm_model2.id}" - SiteSetting.ai_summarization_enabled = true - end - - DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do - SiteSetting.ai_embeddings_semantic_search_hyde_model = "custom:#{llm_model2.id}" - SiteSetting.ai_embeddings_semantic_search_enabled = true - end - - get "/admin/plugins/discourse-ai/ai-llms.json" - - llms = response.parsed_body["ai_llms"] - - model_json = llms.find { |m| m["id"] == llm_model.id } - expect(model_json["used_by"]).to contain_exactly( - { "type" => "ai_bot" }, - { "type" => "ai_helper" }, - ) - - model2_json = llms.find { |m| m["id"] == llm_model2.id } - - expect(model2_json["used_by"]).to contain_exactly( - { "type" => "ai_persona", "name" => "Cool persona", "id" => ai_persona.id }, - { "type" => "ai_summarization" }, - { "type" => "ai_embeddings_semantic_search" }, - ) - end - end - - describe "POST #create" do - let(:valid_attrs) do - { - display_name: "My cool LLM", - name: "gpt-3.5", - provider: "open_ai", - url: "https://test.test/v1/chat/completions", - api_key: "test", - tokenizer: "DiscourseAi::Tokenizer::OpenAiTokenizer", - max_prompt_tokens: 16_000, - } - end - - context "with quotas" do - let(:group) { Fabricate(:group) } - let(:quota_params) do - [{ group_id: group.id, max_tokens: 1000, max_usages: 10, duration_seconds: 86_400 }] - end - - it "creates model with quotas" do - post "/admin/plugins/discourse-ai/ai-llms.json", - params: { - ai_llm: valid_attrs.merge(llm_quotas: quota_params), - } - - expect(response.status).to eq(201) - created_model = LlmModel.last - expect(created_model.llm_quotas.count).to eq(1) - quota = created_model.llm_quotas.first - expect(quota.max_tokens).to eq(1000) - expect(quota.group_id).to eq(group.id) - end - end - - context "with valid attributes" do - it "creates a new LLM model" do - post "/admin/plugins/discourse-ai/ai-llms.json", params: { ai_llm: valid_attrs } - response_body = response.parsed_body - - created_model = response_body["ai_llm"] - - expect(created_model["display_name"]).to eq(valid_attrs[:display_name]) - expect(created_model["name"]).to eq(valid_attrs[:name]) - expect(created_model["provider"]).to eq(valid_attrs[:provider]) - expect(created_model["tokenizer"]).to eq(valid_attrs[:tokenizer]) - expect(created_model["max_prompt_tokens"]).to eq(valid_attrs[:max_prompt_tokens]) - - model = LlmModel.find(created_model["id"]) - expect(model.display_name).to eq(valid_attrs[:display_name]) - end - - it "logs staff action when creating an LLM model" do - # Log the creation - post "/admin/plugins/discourse-ai/ai-llms.json", params: { ai_llm: valid_attrs } - expect(response.status).to eq(201) - - # Now verify the log was created with the right subject - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "create_ai_llm_model", - ).last - expect(history).to be_present - expect(history.subject).to eq(valid_attrs[:display_name]) # Verify subject is set to display_name - end - - it "creates a companion user" do - post "/admin/plugins/discourse-ai/ai-llms.json", - params: { - ai_llm: valid_attrs.merge(enabled_chat_bot: true), - } - - created_model = LlmModel.last - - expect(created_model.user_id).to be_present - end - - it "stores provider-specific config params" do - provider_params = { organization: "Discourse" } - - post "/admin/plugins/discourse-ai/ai-llms.json", - params: { - ai_llm: valid_attrs.merge(provider_params: provider_params), - } - - created_model = LlmModel.last - - expect(created_model.lookup_custom_param("organization")).to eq( - provider_params[:organization], - ) - end - - it "ignores parameters not associated with that provider" do - provider_params = { access_key_id: "random_key" } - - post "/admin/plugins/discourse-ai/ai-llms.json", - params: { - ai_llm: valid_attrs.merge(provider_params: provider_params), - } - - created_model = LlmModel.last - - expect(created_model.lookup_custom_param("access_key_id")).to be_nil - end - end - - context "with invalid attributes" do - it "doesn't create a model" do - post "/admin/plugins/discourse-ai/ai-llms.json", - params: { - ai_llm: valid_attrs.except(:url), - } - - created_model = LlmModel.last - - expect(created_model).to be_nil - end - end - - context "with provider-specific params" do - it "doesn't create a model if a Bedrock param is missing" do - post "/admin/plugins/discourse-ai/ai-llms.json", - params: { - ai_llm: - valid_attrs.merge( - provider: "aws_bedrock", - provider_params: { - region: "us-east-1", - }, - ), - } - - created_model = LlmModel.last - - expect(response.status).to eq(422) - expect(created_model).to be_nil - end - - it "creates the model if all required provider params are present" do - post "/admin/plugins/discourse-ai/ai-llms.json", - params: { - ai_llm: - valid_attrs.merge( - provider: "aws_bedrock", - provider_params: { - region: "us-east-1", - access_key_id: "test", - }, - ), - } - - created_model = LlmModel.last - - expect(response.status).to eq(201) - expect(created_model.lookup_custom_param("region")).to eq("us-east-1") - expect(created_model.lookup_custom_param("access_key_id")).to eq("test") - end - - it "supports boolean values" do - post "/admin/plugins/discourse-ai/ai-llms.json", - params: { - ai_llm: - valid_attrs.merge( - provider: "vllm", - provider_params: { - disable_system_prompt: true, - }, - ), - } - - created_model = LlmModel.last - - expect(response.status).to eq(201) - expect(created_model.lookup_custom_param("disable_system_prompt")).to eq(true) - end - end - end - - describe "PUT #update" do - fab!(:llm_model) - - context "with valid update params" do - let(:update_attrs) { { provider: "anthropic" } } - - context "with quotas" do - it "updates quotas correctly" do - group1 = Fabricate(:group) - group2 = Fabricate(:group) - group3 = Fabricate(:group) - - _quota1 = - Fabricate( - :llm_quota, - llm_model: llm_model, - group: group1, - max_tokens: 1000, - max_usages: 10, - duration_seconds: 86_400, - ) - _quota2 = - Fabricate( - :llm_quota, - llm_model: llm_model, - group: group2, - max_tokens: 2000, - max_usages: 20, - duration_seconds: 86_400, - ) - - put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", - params: { - ai_llm: { - llm_quotas: [ - { - group_id: group1.id, - max_tokens: 1500, - max_usages: 15, - duration_seconds: 43_200, - }, - { - group_id: group3.id, - max_tokens: 3000, - max_usages: 30, - duration_seconds: 86_400, - }, - ], - }, - } - - expect(response.status).to eq(200) - - llm_model.reload - expect(llm_model.llm_quotas.count).to eq(2) - - updated_quota1 = llm_model.llm_quotas.find_by(group: group1) - expect(updated_quota1.max_tokens).to eq(1500) - expect(updated_quota1.max_usages).to eq(15) - expect(updated_quota1.duration_seconds).to eq(43_200) - - expect(llm_model.llm_quotas.find_by(group: group2)).to be_nil - - new_quota = llm_model.llm_quotas.find_by(group: group3) - expect(new_quota).to be_present - expect(new_quota.max_tokens).to eq(3000) - expect(new_quota.max_usages).to eq(30) - expect(new_quota.duration_seconds).to eq(86_400) - end - end - - it "updates the model" do - put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", - params: { - ai_llm: update_attrs, - } - - expect(response.status).to eq(200) - expect(llm_model.reload.provider).to eq(update_attrs[:provider]) - end - - it "logs staff action when updating an LLM model" do - # The initial provider is different from the update - original_provider = llm_model.provider - display_name = llm_model.display_name - - # Perform the update - put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", - params: { - ai_llm: update_attrs, - } - - expect(response.status).to eq(200) - - # Now verify the log was created with the right subject - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_llm_model", - ).last - expect(history).to be_present - expect(history.subject).to eq(display_name) # Verify subject is set to display_name - end - - it "returns a 404 if there is no model with the given Id" do - put "/admin/plugins/discourse-ai/ai-llms/9999999.json" - - expect(response.status).to eq(404) - end - - it "creates a companion user" do - put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", - params: { - ai_llm: update_attrs.merge(enabled_chat_bot: true), - } - - expect(llm_model.reload.user_id).to be_present - end - - it "removes the companion user when desabling the chat bot option" do - llm_model.update!(enabled_chat_bot: true) - llm_model.toggle_companion_user - - put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", - params: { - ai_llm: update_attrs.merge(enabled_chat_bot: false), - } - - expect(llm_model.reload.user_id).to be_nil - end - end - - context "with invalid update params" do - it "doesn't update the model" do - put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", - params: { - ai_llm: { - url: "", - }, - } - - expect(response.status).to eq(422) - end - end - - context "with provider-specific params" do - it "updates provider-specific config params" do - provider_params = { organization: "Discourse" } - - put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", - params: { - ai_llm: { - provider_params: provider_params, - }, - } - - expect(llm_model.reload.lookup_custom_param("organization")).to eq( - provider_params[:organization], - ) - end - - it "ignores parameters not associated with that provider" do - provider_params = { access_key_id: "random_key" } - - put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", - params: { - ai_llm: { - provider_params: provider_params, - }, - } - - expect(llm_model.reload.lookup_custom_param("access_key_id")).to be_nil - end - end - end - - describe "GET #test" do - let(:test_attrs) do - { - name: "llama3", - provider: "hugging_face", - url: "https://test.test/v1/chat/completions", - api_key: "test", - tokenizer: "DiscourseAi::Tokenizer::Llama3Tokenizer", - max_prompt_tokens: 2_000, - } - end - - context "when we can contact the model" do - it "returns a success true flag" do - DiscourseAi::Completions::Llm.with_prepared_responses(["a response"]) do - get "/admin/plugins/discourse-ai/ai-llms/test.json", params: { ai_llm: test_attrs } - - expect(response).to be_successful - expect(response.parsed_body["success"]).to eq(true) - end - end - end - - context "when we cannot contact the model" do - it "returns a success false flag and the error message" do - error_message = { - error: - "Input validation error: `inputs` tokens + `max_new_tokens` must be <= 1512. Given: 30 `inputs` tokens and 3984 `max_new_tokens`", - error_type: "validation", - } - - WebMock.stub_request(:post, test_attrs[:url]).to_return( - status: 422, - body: error_message.to_json, - ) - - get "/admin/plugins/discourse-ai/ai-llms/test.json", params: { ai_llm: test_attrs } - - expect(response).to be_successful - expect(response.parsed_body["success"]).to eq(false) - expect(response.parsed_body["error"]).to eq(error_message.to_json) - end - end - end - - describe "DELETE #destroy" do - fab!(:llm_model) - - it "destroys the requested ai_persona" do - expect { - delete "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json" - - expect(response).to have_http_status(:no_content) - }.to change(LlmModel, :count).by(-1) - end - - it "logs staff action when deleting an LLM model" do - # Capture the model details before deletion for comparison - model_id = llm_model.id - model_display_name = llm_model.display_name - - # Delete the model - delete "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json" - expect(response).to have_http_status(:no_content) - - # Now verify the log was created with the right subject - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "delete_ai_llm_model", - ).last - expect(history).to be_present - expect(history.subject).to eq(model_display_name) # Verify subject is set to display_name - end - - it "validates the model is not in use" do - fake_llm = assign_fake_provider_to(:ai_helper_model) - - delete "/admin/plugins/discourse-ai/ai-llms/#{fake_llm.id}.json" - - expect(response.status).to eq(409) - expect(fake_llm.reload).to eq(fake_llm) - end - - it "cleans up companion users before deleting the model" do - llm_model.update!(enabled_chat_bot: true) - llm_model.toggle_companion_user - companion_user = llm_model.user - - delete "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json" - - expect { companion_user.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - end -end diff --git a/spec/requests/admin/ai_personas_controller_spec.rb b/spec/requests/admin/ai_personas_controller_spec.rb deleted file mode 100644 index 88376a39..00000000 --- a/spec/requests/admin/ai_personas_controller_spec.rb +++ /dev/null @@ -1,983 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Admin::AiPersonasController do - fab!(:admin) - fab!(:ai_persona) - fab!(:embedding_definition) - fab!(:llm_model) - - before do - enable_current_plugin - sign_in(admin) - SiteSetting.ai_embeddings_selected_model = embedding_definition.id - SiteSetting.ai_embeddings_enabled = true - end - - describe "GET #index" do - it "returns a success response" do - get "/admin/plugins/discourse-ai/ai-personas.json" - expect(response).to be_successful - - expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count) - expect(response.parsed_body["meta"]["tools"].length).to eq( - DiscourseAi::Personas::Persona.all_available_tools.length, - ) - end - - it "sideloads llms" do - get "/admin/plugins/discourse-ai/ai-personas.json" - expect(response).to be_successful - - expect(response.parsed_body["meta"]["llms"]).to eq( - [ - { - id: llm_model.id, - name: llm_model.display_name, - vision_enabled: llm_model.vision_enabled, - }.stringify_keys, - ], - ) - end - - it "returns tool options with each tool" do - persona1 = Fabricate(:ai_persona, name: "search1", tools: ["SearchCommand"]) - persona2 = - Fabricate( - :ai_persona, - name: "search2", - tools: [["SearchCommand", { base_query: "test" }, true]], - allow_topic_mentions: true, - allow_personal_messages: true, - allow_chat_channel_mentions: true, - allow_chat_direct_messages: true, - default_llm_id: llm_model.id, - question_consolidator_llm_id: llm_model.id, - forced_tool_count: 2, - ) - persona2.create_user! - - get "/admin/plugins/discourse-ai/ai-personas.json" - expect(response).to be_successful - - serializer_persona1 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona1.id } - serializer_persona2 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona2.id } - - expect(serializer_persona2["allow_topic_mentions"]).to eq(true) - expect(serializer_persona2["allow_personal_messages"]).to eq(true) - expect(serializer_persona2["allow_chat_channel_mentions"]).to eq(true) - expect(serializer_persona2["allow_chat_direct_messages"]).to eq(true) - - expect(serializer_persona2["default_llm_id"]).to eq(llm_model.id) - expect(serializer_persona2["question_consolidator_llm_id"]).to eq(llm_model.id) - expect(serializer_persona2["user_id"]).to eq(persona2.user_id) - expect(serializer_persona2["user"]["id"]).to eq(persona2.user_id) - expect(serializer_persona2["forced_tool_count"]).to eq(2) - - tools = response.parsed_body["meta"]["tools"] - search_tool = tools.find { |c| c["id"] == "Search" } - - expect(search_tool["help"]).to eq(I18n.t("discourse_ai.ai_bot.tool_help.search")) - - expect(search_tool["options"]).to eq( - { - "base_query" => { - "type" => "string", - "name" => I18n.t("discourse_ai.ai_bot.tool_options.search.base_query.name"), - "description" => - I18n.t("discourse_ai.ai_bot.tool_options.search.base_query.description"), - }, - "max_results" => { - "type" => "integer", - "name" => I18n.t("discourse_ai.ai_bot.tool_options.search.max_results.name"), - "description" => - I18n.t("discourse_ai.ai_bot.tool_options.search.max_results.description"), - }, - "search_private" => { - "type" => "boolean", - "name" => I18n.t("discourse_ai.ai_bot.tool_options.search.search_private.name"), - "description" => - I18n.t("discourse_ai.ai_bot.tool_options.search.search_private.description"), - }, - }, - ) - - expect(serializer_persona1["tools"]).to eq(["SearchCommand"]) - expect(serializer_persona2["tools"]).to eq( - [["SearchCommand", { "base_query" => "test" }, true]], - ) - end - - context "with translations" do - before do - SiteSetting.default_locale = "fr" - - TranslationOverride.upsert!( - SiteSetting.default_locale, - "discourse_ai.ai_bot.personas.general.name", - "Général", - ) - TranslationOverride.upsert!( - SiteSetting.default_locale, - "discourse_ai.ai_bot.personas.general.description", - "Général Description", - ) - end - - after do - TranslationOverride.revert!( - SiteSetting.default_locale, - "discourse_ai.ai_bot.personas.general.name", - ) - TranslationOverride.revert!( - SiteSetting.default_locale, - "discourse_ai.ai_bot.personas.general.description", - ) - end - - it "returns localized persona names and descriptions" do - get "/admin/plugins/discourse-ai/ai-personas.json" - - id = DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::General] - persona = response.parsed_body["ai_personas"].find { |p| p["id"] == id } - - expect(persona["name"]).to eq("Général") - expect(persona["description"]).to eq("Général Description") - end - end - end - - describe "GET #edit" do - it "returns a success response" do - get "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}/edit.json" - expect(response).to be_successful - expect(response.parsed_body["ai_persona"]["name"]).to eq(ai_persona.name) - end - - it "includes rag uploads for each persona" do - upload = Fabricate(:upload) - RagDocumentFragment.link_target_and_uploads(ai_persona, [upload.id]) - - get "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}/edit.json" - expect(response).to be_successful - - serialized_persona = response.parsed_body["ai_persona"] - - expect(serialized_persona.dig("rag_uploads", 0, "id")).to eq(upload.id) - expect(serialized_persona.dig("rag_uploads", 0, "original_filename")).to eq( - upload.original_filename, - ) - end - end - - describe "POST #create" do - context "with valid params" do - let(:valid_attributes) do - { - name: "superbot", - description: "Assists with tasks", - system_prompt: "you are a helpful bot", - tools: [["search", { "base_query" => "test" }, true]], - top_p: 0.1, - temperature: 0.5, - allow_topic_mentions: true, - allow_personal_messages: true, - allow_chat_channel_mentions: true, - allow_chat_direct_messages: true, - default_llm_id: llm_model.id, - question_consolidator_llm_id: llm_model.id, - forced_tool_count: 2, - response_format: [{ key: "summary", type: "string" }], - examples: [%w[user_msg1 assistant_msg1], %w[user_msg2 assistant_msg2]], - } - end - - it "creates a new AiPersona" do - expect { - post "/admin/plugins/discourse-ai/ai-personas.json", - params: { ai_persona: valid_attributes }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - - expect(response).to be_successful - persona_json = response.parsed_body["ai_persona"] - - expect(persona_json["name"]).to eq("superbot") - expect(persona_json["top_p"]).to eq(0.1) - expect(persona_json["temperature"]).to eq(0.5) - expect(persona_json["default_llm_id"]).to eq(llm_model.id) - expect(persona_json["forced_tool_count"]).to eq(2) - expect(persona_json["allow_topic_mentions"]).to eq(true) - expect(persona_json["allow_personal_messages"]).to eq(true) - expect(persona_json["allow_chat_channel_mentions"]).to eq(true) - expect(persona_json["allow_chat_direct_messages"]).to eq(true) - expect(persona_json["question_consolidator_llm_id"]).to eq(llm_model.id) - expect(persona_json["response_format"].map { |rf| rf["key"] }).to contain_exactly( - "summary", - ) - expect(persona_json["examples"]).to eq(valid_attributes[:examples]) - - persona = AiPersona.find(persona_json["id"]) - - expect(persona.tools).to eq([["search", { "base_query" => "test" }, true]]) - expect(persona.top_p).to eq(0.1) - expect(persona.temperature).to eq(0.5) - }.to change(AiPersona, :count).by(1) - end - - it "logs staff action when creating a persona" do - # Create the persona - post "/admin/plugins/discourse-ai/ai-personas.json", - params: { ai_persona: valid_attributes }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - - expect(response).to be_successful - - # Now verify the log was created with the right subject - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "create_ai_persona", - ).last - expect(history).to be_present - expect(history.subject).to eq("superbot") # Verify subject is set to name - end - end - - context "with invalid params" do - it "renders a JSON response with errors for the new ai_persona" do - post "/admin/plugins/discourse-ai/ai-personas.json", params: { ai_persona: { foo: "" } } # invalid attribute - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to include("application/json") - end - end - end - - describe "POST #create_user" do - it "creates a user for the persona" do - post "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}/create-user.json" - ai_persona.reload - - expect(response).to be_successful - expect(response.parsed_body["user"]["id"]).to eq(ai_persona.user_id) - end - end - - describe "PUT #update" do - context "with scoped api key" do - it "allows updates with a properly scoped API key" do - api_key = Fabricate(:api_key, user: admin, created_by: admin) - - scope = - ApiKeyScope.create!( - resource: "discourse_ai", - action: "update_personas", - api_key_id: api_key.id, - allowed_parameters: { - }, - ) - - put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json", - params: { - ai_persona: { - name: "UpdatedByAPI", - description: "Updated via API key", - }, - }, - headers: { - "Api-Key" => api_key.key, - "Api-Username" => admin.username, - } - - expect(response).to have_http_status(:ok) - ai_persona.reload - expect(ai_persona.name).to eq("UpdatedByAPI") - expect(ai_persona.description).to eq("Updated via API key") - - scope.update!(action: "fake") - - put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json", - params: { - ai_persona: { - name: "UpdatedByAPI 2", - description: "Updated via API key", - }, - }, - headers: { - "Api-Key" => api_key.key, - "Api-Username" => admin.username, - } - - expect(response).not_to have_http_status(:ok) - end - end - - it "allows us to trivially clear top_p and temperature" do - persona = Fabricate(:ai_persona, name: "test_bot2", top_p: 0.5, temperature: 0.1) - put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json", - params: { - ai_persona: { - top_p: "", - temperature: "", - }, - } - - expect(response).to have_http_status(:ok) - persona.reload - - expect(persona.top_p).to eq(nil) - expect(persona.temperature).to eq(nil) - end - - it "logs staff action when updating a persona" do - persona = Fabricate(:ai_persona, name: "original_name", description: "original description") - - # Update the persona - put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json", - params: { - ai_persona: { - name: "updated_name", - description: "updated description", - }, - } - - expect(response).to have_http_status(:ok) - persona.reload - expect(persona.name).to eq("updated_name") - expect(persona.description).to eq("updated description") - - # Now verify the log was created with the right subject - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_persona", - ).last - expect(history).to be_present - expect(history.subject).to eq("updated_name") # Verify subject is set to the new name - end - - it "supports updating rag params" do - persona = Fabricate(:ai_persona, name: "test_bot2") - - put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json", - params: { - ai_persona: { - rag_chunk_tokens: "102", - rag_chunk_overlap_tokens: "12", - rag_conversation_chunks: "13", - rag_llm_model_id: llm_model.id, - question_consolidator_llm_id: llm_model.id, - }, - } - - expect(response).to have_http_status(:ok) - persona.reload - - expect(persona.rag_chunk_tokens).to eq(102) - expect(persona.rag_chunk_overlap_tokens).to eq(12) - expect(persona.rag_conversation_chunks).to eq(13) - expect(persona.rag_llm_model_id).to eq(llm_model.id) - expect(persona.question_consolidator_llm_id).to eq(llm_model.id) - end - - it "supports updating vision params" do - persona = Fabricate(:ai_persona, name: "test_bot2") - put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json", - params: { - ai_persona: { - vision_enabled: true, - vision_max_pixels: 512 * 512, - }, - } - - expect(response).to have_http_status(:ok) - persona.reload - - expect(persona.vision_enabled).to eq(true) - expect(persona.vision_max_pixels).to eq(512 * 512) - end - - it "does not allow temperature and top p changes on stock personas" do - put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json", - params: { - ai_persona: { - top_p: 0.5, - temperature: 0.1, - }, - } - - expect(response).to have_http_status(:unprocessable_entity) - end - - context "with valid params" do - it "updates the requested ai_persona" do - put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json", - params: { - ai_persona: { - name: "SuperBot", - enabled: false, - tools: ["search"], - }, - } - - expect(response).to have_http_status(:ok) - expect(response.content_type).to include("application/json") - - ai_persona.reload - expect(ai_persona.name).to eq("SuperBot") - expect(ai_persona.enabled).to eq(false) - expect(ai_persona.tools).to eq([["search", nil, false]]) - end - end - - context "with system personas" do - it "does not allow editing of system prompts" do - put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json", - params: { - ai_persona: { - system_prompt: "you are not a helpful bot", - }, - } - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.parsed_body["errors"].join).not_to be_blank - expect(response.parsed_body["errors"].join).not_to include("en.discourse") - end - - it "does not allow editing of tools" do - put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json", - params: { - ai_persona: { - tools: %w[SearchCommand ImageCommand], - }, - } - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.parsed_body["errors"].join).not_to be_blank - expect(response.parsed_body["errors"].join).not_to include("en.discourse") - end - - it "does not allow editing of name and description cause it is localized" do - put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json", - params: { - ai_persona: { - name: "bob", - description: "the bob", - }, - } - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.parsed_body["errors"].join).not_to be_blank - expect(response.parsed_body["errors"].join).not_to include("en.discourse") - end - - it "does allow some actions" do - put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json", - params: { - ai_persona: { - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]], - enabled: false, - priority: 989, - }, - } - - expect(response).to be_successful - end - end - - context "with invalid params" do - it "renders a JSON response with errors for the ai_persona" do - put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json", - params: { - ai_persona: { - name: "", - }, - } # invalid attribute - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to include("application/json") - end - end - end - - describe "GET #export" do - fab!(:ai_tool) do - AiTool.create!( - name: "Test Tool", - tool_name: "test_tool", - description: "A test tool", - script: "function invoke(params) { return 'test'; }", - parameters: [{ name: "query", type: "string", required: true }], - summary: "Test tool summary", - created_by_id: admin.id, - ) - end - - fab!(:persona_with_tools) do - AiPersona.create!( - name: "ToolMaster", - description: "A persona with custom tools", - system_prompt: "You are a tool master", - tools: [ - ["SearchCommand", { "base_query" => "test" }, true], - ["custom-#{ai_tool.id}", { "max_results" => 10 }, false], - ], - temperature: 0.8, - top_p: 0.9, - response_format: [{ type: "string", key: "summary" }], - examples: [["user example", "assistant example"]], - default_llm_id: llm_model.id, - ) - end - - it "exports a persona as JSON" do - get "/admin/plugins/discourse-ai/ai-personas/#{persona_with_tools.id}/export.json" - - expect(response).to be_successful - expect(response.headers["Content-Disposition"]).to include("attachment") - expect(response.headers["Content-Disposition"]).to include("toolmaster.json") - - json = response.parsed_body - expect(json["meta"]["version"]).to eq("1.0") - expect(json["meta"]["exported_at"]).to be_present - - persona_data = json["persona"] - expect(persona_data["name"]).to eq("ToolMaster") - expect(persona_data["description"]).to eq("A persona with custom tools") - expect(persona_data["system_prompt"]).to eq("You are a tool master") - expect(persona_data["temperature"]).to eq(0.8) - expect(persona_data["top_p"]).to eq(0.9) - expect(persona_data["response_format"]).to eq([{ "type" => "string", "key" => "summary" }]) - expect(persona_data["examples"]).to eq([["user example", "assistant example"]]) - - # Check that custom tool ID is replaced with tool_name - expect(persona_data["tools"]).to include( - ["SearchCommand", { "base_query" => "test" }, true], - ["custom-test_tool", { "max_results" => 10 }, false], - ) - - # Check custom tools are exported - expect(json["custom_tools"]).to be_an(Array) - expect(json["custom_tools"].length).to eq(1) - - custom_tool = json["custom_tools"].first - expect(custom_tool["identifier"]).to eq("test_tool") - expect(custom_tool["name"]).to eq("Test Tool") - expect(custom_tool["description"]).to eq("A test tool") - expect(custom_tool["script"]).to eq("function invoke(params) { return 'test'; }") - expect(custom_tool["parameters"]).to eq( - [{ "name" => "query", "type" => "string", "required" => true }], - ) - end - - it "handles personas without custom tools" do - persona = Fabricate(:ai_persona, tools: ["SearchCommand"]) - - get "/admin/plugins/discourse-ai/ai-personas/#{persona.id}/export.json" - - expect(response).to be_successful - json = response.parsed_body - expect(json["custom_tools"]).to eq([]) - expect(json["persona"]["tools"]).to eq(["SearchCommand"]) - end - - it "returns 404 for non-existent persona" do - get "/admin/plugins/discourse-ai/ai-personas/999999/export.json" - expect(response).to have_http_status(:not_found) - end - end - - describe "POST #import" do - let(:valid_import_data) do - { - meta: { - version: "1.0", - exported_at: Time.zone.now.iso8601, - }, - persona: { - name: "ImportedPersona", - description: "An imported persona", - system_prompt: "You are an imported assistant", - temperature: 0.7, - top_p: 0.8, - response_format: [{ type: "string", key: "answer" }], - examples: [["hello", "hi there"]], - tools: ["SearchCommand", ["ReadCommand", { max_length: 1000 }, true]], - }, - custom_tools: [], - } - end - - it "imports a new persona successfully" do - expect { - post "/admin/plugins/discourse-ai/ai-personas/import.json", - params: valid_import_data, - as: :json - expect(response).to have_http_status(:created) - }.to change(AiPersona, :count).by(1) - - persona = AiPersona.find_by(name: "ImportedPersona") - expect(persona).to be_present - expect(persona.description).to eq("An imported persona") - expect(persona.system_prompt).to eq("You are an imported assistant") - expect(persona.temperature).to eq(0.7) - expect(persona.top_p).to eq(0.8) - expect(persona.response_format).to eq([{ "type" => "string", "key" => "answer" }]) - expect(persona.examples).to eq([["hello", "hi there"]]) - expect(persona.tools).to eq( - ["SearchCommand", ["ReadCommand", { "max_length" => 1000 }, true]], - ) - end - - it "imports a persona with custom tools" do - import_data_with_tools = valid_import_data.deep_dup - import_data_with_tools[:persona][:tools] = [ - "SearchCommand", - ["custom-my_custom_tool", { param1: "value1" }, false], - ] - import_data_with_tools[:custom_tools] = [ - { - identifier: "my_custom_tool", - name: "My Custom Tool", - description: "A custom tool for testing", - tool_name: "my_custom_tool", - parameters: [{ name: "param1", type: "string", required: true }], - summary: "Custom tool summary", - script: "function invoke(params) { return params.param1; }", - }, - ] - - expect { - post "/admin/plugins/discourse-ai/ai-personas/import.json", - params: import_data_with_tools, - as: :json - }.to change(AiPersona, :count).by(1).and change(AiTool, :count).by(1) - - expect(response).to have_http_status(:created) - - persona = AiPersona.find_by(name: "ImportedPersona") - expect(persona).to be_present - - tool = AiTool.find_by(tool_name: "my_custom_tool") - expect(tool).to be_present - expect(tool.name).to eq("My Custom Tool") - expect(tool.description).to eq("A custom tool for testing") - expect(tool.script).to eq("function invoke(params) { return params.param1; }") - - # Check that the tool reference uses the ID - expect(persona.tools).to include( - "SearchCommand", - ["custom-#{tool.id}", { "param1" => "value1" }, false], - ) - end - - it "prevents importing duplicate personas by default" do - _existing_persona = Fabricate(:ai_persona, name: "ImportedPersona") - - post "/admin/plugins/discourse-ai/ai-personas/import.json", - params: valid_import_data, - as: :json - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.parsed_body["errors"].join).to include("ImportedPersona") - end - - it "allows overwriting existing personas with force=true" do - existing_persona = - Fabricate(:ai_persona, name: "ImportedPersona", description: "Old description") - - import_data = valid_import_data.merge(force: true) - - expect { - post "/admin/plugins/discourse-ai/ai-personas/import.json", params: import_data, as: :json - }.not_to change(AiPersona, :count) - - expect(response).to have_http_status(:ok) - - existing_persona.reload - expect(existing_persona.description).to eq("An imported persona") - expect(existing_persona.system_prompt).to eq("You are an imported assistant") - end - - it "handles invalid import data gracefully" do - invalid_data = { invalid: "data" } - - post "/admin/plugins/discourse-ai/ai-personas/import.json", params: invalid_data, as: :json - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.parsed_body["errors"]).to be_present - end - - it "handles missing persona data" do - invalid_data = { meta: { version: "1.0" } } - - post "/admin/plugins/discourse-ai/ai-personas/import.json", params: invalid_data, as: :json - - expect(response).to have_http_status(:unprocessable_entity) - end - - it "logs staff action when importing a new persona" do - post "/admin/plugins/discourse-ai/ai-personas/import.json", - params: valid_import_data, - as: :json - - expect(response).to have_http_status(:created) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "create_ai_persona", - ).last - expect(history).to be_present - expect(history.subject).to eq("ImportedPersona") - end - - it "logs staff action when updating an existing persona" do - _existing_persona = Fabricate(:ai_persona, name: "ImportedPersona") - - import_data = valid_import_data.merge(force: true) - - post "/admin/plugins/discourse-ai/ai-personas/import.json", params: import_data, as: :json - - expect(response).to have_http_status(:ok) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_persona", - ).last - expect(history).to be_present - expect(history.subject).to eq("ImportedPersona") - end - end - - describe "DELETE #destroy" do - it "destroys the requested ai_persona" do - expect { - delete "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json" - - expect(response).to have_http_status(:no_content) - }.to change(AiPersona, :count).by(-1) - end - - it "logs staff action when deleting a persona" do - # Capture persona details before deletion - _persona_id = ai_persona.id - persona_name = ai_persona.name - - # Delete the persona - delete "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json" - expect(response).to have_http_status(:no_content) - - # Now verify the log was created with the right subject - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "delete_ai_persona", - ).last - expect(history).to be_present - expect(history.subject).to eq(persona_name) # Verify subject is set to name - end - - it "is not allowed to delete system personas" do - expect { - delete "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}.json" - expect(response).to have_http_status(:unprocessable_entity) - expect(response.parsed_body["errors"].join).not_to be_blank - # let's make sure this is translated - expect(response.parsed_body["errors"].join).not_to include("en.discourse") - }.not_to change(AiPersona, :count) - end - end - - describe "#stream_reply" do - fab!(:llm) { Fabricate(:llm_model, name: "fake_llm", provider: "fake") } - let(:fake_endpoint) { DiscourseAi::Completions::Endpoints::Fake } - - before { fake_endpoint.delays = [] } - - after { fake_endpoint.reset! } - - it "ensures persona exists" do - post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json" - expect(response).to have_http_status(:unprocessable_entity) - # this ensures localization key is actually in the yaml - expect(response.body).to include("persona_name") - end - - it "ensures question exists" do - ai_persona.update!(default_llm_id: llm.id) - - post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json", - params: { - persona_id: ai_persona.id, - user_unique_id: "site:test.com:user_id:1", - } - expect(response).to have_http_status(:unprocessable_entity) - expect(response.body).to include("query") - end - - it "ensure persona has a user specified" do - ai_persona.update!(default_llm_id: llm.id) - - post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json", - params: { - persona_id: ai_persona.id, - query: "how are you today?", - user_unique_id: "site:test.com:user_id:1", - } - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.body).to include("associated") - end - - def validate_streamed_response(raw_http, expected) - lines = raw_http.split("\r\n") - - header_lines, _, payload_lines = lines.chunk { |l| l == "" }.map(&:last) - - preamble = (<<~PREAMBLE).strip - HTTP/1.1 200 OK - Content-Type: text/plain; charset=utf-8 - Transfer-Encoding: chunked - Cache-Control: no-cache, no-store, must-revalidate - Connection: close - X-Accel-Buffering: no - X-Content-Type-Options: nosniff - PREAMBLE - - expect(header_lines.join("\n")).to eq(preamble) - - parsed = +"" - - context_info = nil - - payload_lines.each_slice(2) do |size, data| - size = size.to_i(16) - data = data.to_s - expect(data.bytesize).to eq(size) - - if size > 0 - json = JSON.parse(data) - parsed << json["partial"].to_s - - context_info = json if json["topic_id"] - end - end - - expect(parsed).to eq(expected) - - context_info - end - - it "is able to create a new conversation" do - Jobs.run_immediately! - # trust level 0 - SiteSetting.ai_bot_allowed_groups = "10" - - fake_endpoint.fake_content = ["This is a test! Testing!", "An amazing title"] - - ai_persona.create_user! - ai_persona.update!( - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - default_llm_id: llm.id, - allow_personal_messages: true, - system_prompt: "you are a helpful bot", - ) - - io_out, io_in = IO.pipe - - post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json", - params: { - persona_name: ai_persona.name, - query: "how are you today?", - user_unique_id: "site:test.com:user_id:1", - preferred_username: "test_user", - custom_instructions: "To be appended to system prompt", - }, - env: { - "rack.hijack" => lambda { io_in }, - } - - # this is a fake response but it catches errors - expect(response).to have_http_status(:no_content) - - raw = io_out.read - context_info = validate_streamed_response(raw, "This is a test! Testing!") - - system_prompt = fake_endpoint.previous_calls[-2][:dialect].prompt.messages.first[:content] - - expect(system_prompt).to eq("you are a helpful bot\nTo be appended to system prompt") - - expect(context_info["topic_id"]).to be_present - topic = Topic.find(context_info["topic_id"]) - last_post = topic.posts.order(:created_at).last - expect(last_post.raw).to eq("This is a test! Testing!") - - user_post = topic.posts.find_by(post_number: 1) - expect(user_post.raw).to eq("how are you today?") - - # need ai persona and user - expect(topic.topic_allowed_users.count).to eq(2) - expect(topic.archetype).to eq(Archetype.private_message) - expect(topic.title).to eq("An amazing title") - expect(topic.posts.count).to eq(2) - - tool_call = - DiscourseAi::Completions::ToolCall.new(name: "categories", parameters: {}, id: "tool_1") - - fake_endpoint.fake_content = [tool_call, "this is the response after the tool"] - # this simplifies function calls - fake_endpoint.chunk_count = 1 - - ai_persona.update!(tools: ["Categories"]) - - # lets also unstage the user and add the user to tl0 - # this will ensure there are no feedback loops - new_user = user_post.user - new_user.update!(staged: false) - Group.user_trust_level_change!(new_user.id, new_user.trust_level) - - # double check this happened and user is in group - personas = AiPersona.allowed_modalities(user: new_user.reload, allow_personal_messages: true) - expect(personas.count).to eq(1) - - io_out, io_in = IO.pipe - - post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json", - params: { - persona_id: ai_persona.id, - query: "how are you now?", - user_unique_id: "site:test.com:user_id:1", - preferred_username: "test_user", - topic_id: context_info["topic_id"], - }, - env: { - "rack.hijack" => lambda { io_in }, - } - - # this is a fake response but it catches errors - expect(response).to have_http_status(:no_content) - - raw = io_out.read - context_info = validate_streamed_response(raw, "this is the response after the tool") - - topic = topic.reload - last_post = topic.posts.order(:created_at).last - - expect(last_post.raw).to end_with("this is the response after the tool") - # function call is visible in the post - expect(last_post.raw[0..8]).to eq("
    ") - - user_post = topic.posts.find_by(post_number: 3) - expect(user_post.raw).to eq("how are you now?") - expect(user_post.user.username).to eq("test_user") - expect(user_post.user.custom_fields).to eq( - { "ai-stream-conversation-unique-id" => "site:test.com:user_id:1" }, - ) - - expect(topic.posts.count).to eq(4) - end - end -end diff --git a/spec/requests/admin/ai_spam_controller_spec.rb b/spec/requests/admin/ai_spam_controller_spec.rb deleted file mode 100644 index 713fc407..00000000 --- a/spec/requests/admin/ai_spam_controller_spec.rb +++ /dev/null @@ -1,498 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Admin::AiSpamController do - fab!(:admin) - fab!(:user) - fab!(:llm_model) - - before { enable_current_plugin } - - describe "#update" do - context "when logged in as admin" do - before { sign_in(admin) } - - it "can update settings from scratch" do - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - is_enabled: true, - llm_model_id: llm_model.id, - ai_persona_id: - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::SpamDetector], - custom_instructions: "custom instructions", - } - - expect(response.status).to eq(200) - expect(SiteSetting.ai_spam_detection_enabled).to eq(true) - expect(AiModerationSetting.spam.llm_model_id).to eq(llm_model.id) - expect(AiModerationSetting.spam.ai_persona_id).to eq( - DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::SpamDetector], - ) - expect(AiModerationSetting.spam.data["custom_instructions"]).to eq("custom instructions") - end - - it "denies update for disallowed seeded llm" do - seeded_llm = Fabricate(:llm_model, id: -1) - - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - is_enabled: true, - llm_model_id: seeded_llm.id, - custom_instructions: "custom instructions", - } - - expect(response.status).to eq(422) - - SiteSetting.ai_spam_detection_model_allowed_seeded_models = seeded_llm.id.to_s - - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - is_enabled: true, - llm_model_id: seeded_llm.id, - custom_instructions: "custom instructions", - } - - expect(response.status).to eq(200) - end - - it "validates the selected persona has a valid response format" do - ai_persona = Fabricate(:ai_persona, response_format: nil) - - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - is_enabled: true, - llm_model_id: llm_model.id, - ai_persona_id: ai_persona.id, - custom_instructions: "custom instructions", - } - - expect(response.status).to eq(422) - - ai_persona.update!(response_format: [{ "key" => "spam", "type" => "boolean" }]) - - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - is_enabled: true, - llm_model_id: llm_model.id, - ai_persona_id: ai_persona.id, - custom_instructions: "custom instructions", - } - - expect(response.status).to eq(200) - expect(AiModerationSetting.spam.ai_persona_id).to eq(ai_persona.id) - end - - it "ensures that seeded llm ID is properly passed and allowed" do - seeded_llm = Fabricate(:seeded_model) - - SiteSetting.ai_spam_detection_model_allowed_seeded_models = [ - llm_model.id, - seeded_llm.id, - ].join("|") - - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - is_enabled: true, - llm_model_id: seeded_llm.id, - custom_instructions: "custom instructions", - } - expect(SiteSetting.ai_spam_detection_model_allowed_seeded_models).to eq( - "#{llm_model.id}|#{seeded_llm.id}", - ) - expect(response.status).to eq(200) - end - - it "can not enable spam detection without a model selected" do - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - custom_instructions: "custom instructions", - } - expect(response.status).to eq(422) - end - - it "can not fiddle with custom instructions without an llm" do - put "/admin/plugins/discourse-ai/ai-spam.json", params: { is_enabled: true } - expect(response.status).to eq(422) - end - - context "when spam detection was already set" do - fab!(:setting) do - AiModerationSetting.create( - { - setting_type: :spam, - llm_model_id: llm_model.id, - data: { - custom_instructions: "custom instructions", - }, - }, - ) - end - - it "can partially update settings" do - put "/admin/plugins/discourse-ai/ai-spam.json", params: { is_enabled: false } - - expect(response.status).to eq(200) - expect(SiteSetting.ai_spam_detection_enabled).to eq(false) - expect(AiModerationSetting.spam.llm_model_id).to eq(llm_model.id) - expect(AiModerationSetting.spam.data["custom_instructions"]).to eq("custom instructions") - end - - it "can update pre existing settings" do - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - is_enabled: true, - llm_model_id: llm_model.id, - custom_instructions: "custom instructions new", - } - - expect(response.status).to eq(200) - expect(SiteSetting.ai_spam_detection_enabled).to eq(true) - expect(AiModerationSetting.spam.llm_model_id).to eq(llm_model.id) - expect(AiModerationSetting.spam.data["custom_instructions"]).to eq( - "custom instructions new", - ) - end - - it "logs staff action when custom_instructions change" do - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - is_enabled: true, - llm_model_id: llm_model.id, - custom_instructions: "updated instructions", - } - - expect(response.status).to eq(200) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_spam_settings", - ).last - expect(history).to be_present - expect(history.details).to include("custom_instructions") - end - - it "logs staff action when llm_model_id changes" do - # Create another model to change to - new_llm_model = - Fabricate(:llm_model, name: "New Test Model", display_name: "New Test Model") - - put "/admin/plugins/discourse-ai/ai-spam.json", params: { llm_model_id: new_llm_model.id } - - expect(response.status).to eq(200) - - # Verify the log was created with the right subject - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_spam_settings", - ).last - expect(history).to be_present - expect(history.details).to include("llm_model_id") - end - - it "logs staff actio when ai_persona_id changes" do - new_persona = - Fabricate( - :ai_persona, - name: "Updated Persona", - response_format: [{ "key" => "spam", "type" => "boolean" }], - ) - - put "/admin/plugins/discourse-ai/ai-spam.json", params: { ai_persona_id: new_persona.id } - - expect(response.status).to eq(200) - - # Verify the log was created with the right subject - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_spam_settings", - ).last - expect(history).to be_present - expect(history.details).to include("ai_persona_id") - expect(history.details).to include(new_persona.name) - end - - it "does not log staff action when only is_enabled changes" do - # Check initial count of logs - initial_count = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_spam_settings", - ).count - - # Update only the is_enabled setting - put "/admin/plugins/discourse-ai/ai-spam.json", params: { is_enabled: false } - - expect(response.status).to eq(200) - - # Verify no new log was created - current_count = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_spam_settings", - ).count - expect(current_count).to eq(initial_count) - end - - it "logs both custom_instructions and llm_model_id changes in one entry" do - # Create another model to change to - new_llm_model = - Fabricate(:llm_model, name: "Another Test Model", display_name: "Another Test Model") - - put "/admin/plugins/discourse-ai/ai-spam.json", - params: { - llm_model_id: new_llm_model.id, - custom_instructions: "new instructions for both changes", - } - - expect(response.status).to eq(200) - - # Verify the log was created with all changes - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_spam_settings", - ).last - expect(history).to be_present - expect(history.details).to include("llm_model_id") - expect(history.details).to include("custom_instructions") - end - end - end - end - - describe "#test" do - fab!(:spam_post) { Fabricate(:post) } - fab!(:spam_post2) { Fabricate(:post, topic: spam_post.topic, raw: "something special 123") } - fab!(:setting) do - AiModerationSetting.create( - { - setting_type: :spam, - llm_model_id: llm_model.id, - data: { - custom_instructions: "custom instructions", - }, - }, - ) - end - - before { sign_in(admin) } - - it "can scan using post url (even when trashed and user deleted)" do - User.where(id: spam_post2.user_id).delete_all - spam_post2.topic.trash! - spam_post2.trash! - - llm2 = Fabricate(:llm_model, name: "DiffLLM") - - DiscourseAi::Completions::Llm.with_prepared_responses([true, "just because"]) do - post "/admin/plugins/discourse-ai/ai-spam/test.json", - params: { - post_url: spam_post2.url, - llm_id: llm2.id, - } - end - - expect(response.status).to eq(200) - - parsed = response.parsed_body - expect(parsed["log"]).to include(spam_post2.raw) - expect(parsed["log"]).to include("DiffLLM") - end - - it "can scan using post id" do - DiscourseAi::Completions::Llm.with_prepared_responses([true, "because apples"]) do - post "/admin/plugins/discourse-ai/ai-spam/test.json", - params: { - post_url: spam_post.id.to_s, - } - end - - expect(response.status).to eq(200) - - parsed = response.parsed_body - expect(parsed["log"]).to include(spam_post.raw) - end - - it "returns proper spam test results" do - freeze_time DateTime.parse("2000-01-01") - - AiSpamLog.create!( - post: spam_post, - llm_model: llm_model, - is_spam: false, - created_at: 2.days.ago, - ) - - AiSpamLog.create!(post: spam_post, llm_model: llm_model, is_spam: true, created_at: 1.day.ago) - - DiscourseAi::Completions::Llm.with_prepared_responses([true, "because banana"]) do - post "/admin/plugins/discourse-ai/ai-spam/test.json", - params: { - post_url: spam_post.url, - custom_instructions: "special custom instructions", - } - end - - expect(response.status).to eq(200) - - parsed = response.parsed_body - expect(parsed["log"]).to include("special custom instructions") - expect(parsed["log"]).to include(spam_post.raw) - expect(parsed["is_spam"]).to eq(true) - expect(parsed["log"]).to include("Scan History:") - expect(parsed["log"]).to include("banana") - end - end - - describe "#show" do - context "when logged in as admin" do - before do - sign_in(admin) - AiModerationSetting.create!(setting_type: :spam, llm_model_id: llm_model.id) - end - - it "correctly filters seeded llms" do - SiteSetting.ai_spam_detection_enabled = true - seeded_llm = Fabricate(:seeded_model) - - get "/admin/plugins/discourse-ai/ai-spam.json" - expect(response.status).to eq(200) - json = response.parsed_body - - # only includes fabricated model - expect(json["available_llms"].length).to eq(1) - - SiteSetting.ai_spam_detection_model_allowed_seeded_models = seeded_llm.id.to_s - - get "/admin/plugins/discourse-ai/ai-spam.json" - expect(response.status).to eq(200) - json = response.parsed_body - - expect(json["available_llms"].length).to eq(2) - end - - it "returns the serialized spam settings" do - SiteSetting.ai_spam_detection_enabled = true - - get "/admin/plugins/discourse-ai/ai-spam.json" - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["is_enabled"]).to eq(true) - expect(json["selected_llm"]).to eq(nil) - expect(json["custom_instructions"]).to eq(nil) - expect(json["available_llms"]).to be_an(Array) - expect(json["stats"]).to be_present - end - - it "return proper settings when spam detection is enabled" do - SiteSetting.ai_spam_detection_enabled = true - - AiModerationSetting.update!( - { - setting_type: :spam, - llm_model_id: llm_model.id, - data: { - custom_instructions: "custom instructions", - }, - }, - ) - - flagging_user = DiscourseAi::AiModeration::SpamScanner.flagging_user - expect(flagging_user.id).not_to eq(Discourse.system_user.id) - - AiSpamLog.create!(post_id: 1, llm_model_id: llm_model.id, is_spam: true, payload: "test") - - get "/admin/plugins/discourse-ai/ai-spam.json" - - json = response.parsed_body - expect(json["is_enabled"]).to eq(true) - expect(json["llm_id"]).to eq(llm_model.id) - expect(json["custom_instructions"]).to eq("custom instructions") - - expect(json["stats"].to_h).to eq( - "scanned_count" => 1, - "spam_detected" => 1, - "false_positives" => 0, - "false_negatives" => 0, - ) - - expect(json["flagging_username"]).to eq(flagging_user.username) - end - end - - context "when not logged in as admin" do - it "returns 404 for anonymous users" do - get "/admin/plugins/discourse-ai/ai-spam.json" - expect(response.status).to eq(404) - end - - it "returns 404 for regular users" do - sign_in(user) - get "/admin/plugins/discourse-ai/ai-spam.json" - expect(response.status).to eq(404) - end - end - - context "when plugin is disabled" do - before do - sign_in(admin) - SiteSetting.discourse_ai_enabled = false - end - - it "returns 404" do - get "/admin/plugins/discourse-ai/ai-spam.json" - expect(response.status).to eq(404) - end - end - end - - describe "#fix_errors" do - fab!(:setting) do - AiModerationSetting.create( - { - setting_type: :spam, - llm_model_id: llm_model.id, - data: { - custom_instructions: "custom instructions", - }, - }, - ) - fab!(:llm_model) - - before do - sign_in(admin) - DiscourseAi::AiModeration::SpamScanner.flagging_user.update!(admin: false) - end - - it "resolves spam scanner not admin error" do - post "/admin/plugins/discourse-ai/ai-spam/fix-errors", - params: { - error: "spam_scanner_not_admin", - } - - expect(response.status).to eq(200) - expect(DiscourseAi::AiModeration::SpamScanner.flagging_user.reload.admin).to eq(true) - end - - it "returns an error when it can't update the user" do - DiscourseAi::AiModeration::SpamScanner.flagging_user.destroy - - post "/admin/plugins/discourse-ai/ai-spam/fix-errors", - params: { - error: "spam_scanner_not_admin", - } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to be_present - expect(response.parsed_body["errors"].first).to eq( - I18n.t("discourse_ai.spam_detection.bot_user_update_failed"), - ) - end - end - end -end diff --git a/spec/requests/admin/ai_tools_controller_spec.rb b/spec/requests/admin/ai_tools_controller_spec.rb deleted file mode 100644 index 618435c2..00000000 --- a/spec/requests/admin/ai_tools_controller_spec.rb +++ /dev/null @@ -1,403 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Admin::AiToolsController do - fab!(:llm_model) - fab!(:admin) - fab!(:ai_tool) do - AiTool.create!( - name: "Test Tool", - tool_name: "test_tool", - description: "A test tool", - script: "function invoke(params) { return params; }", - parameters: [ - { - name: "unit", - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[c f], - required: true, - }, - ], - summary: "Test tool summary", - created_by_id: -1, - ) - end - - before do - enable_current_plugin - sign_in(admin) - SiteSetting.ai_embeddings_enabled = true - end - - describe "GET #index" do - it "returns a success response" do - get "/admin/plugins/discourse-ai/ai-tools.json" - expect(response).to be_successful - expect(response.parsed_body["ai_tools"].length).to eq(AiTool.count) - expect(response.parsed_body["meta"]["presets"].length).to be > 0 - expect(response.parsed_body["meta"]["llms"].length).to be > 0 - end - end - - describe "GET #edit" do - it "returns a success response" do - get "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}/edit.json" - expect(response).to be_successful - expect(response.parsed_body["ai_tool"]["name"]).to eq(ai_tool.name) - end - end - - describe "GET #export" do - it "returns the ai_tool as JSON attachment" do - get "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}/export.json" - - expect(response).to be_successful - expect(response.headers["Content-Disposition"]).to eq( - "attachment; filename=\"#{ai_tool.tool_name}.json\"", - ) - expect(response.parsed_body["ai_tool"]["name"]).to eq(ai_tool.name) - expect(response.parsed_body["ai_tool"]["tool_name"]).to eq(ai_tool.tool_name) - expect(response.parsed_body["ai_tool"]["description"]).to eq(ai_tool.description) - expect(response.parsed_body["ai_tool"]["parameters"]).to eq(ai_tool.parameters) - end - - it "returns 404 for non-existent ai_tool" do - get "/admin/plugins/discourse-ai/ai-tools/99999/export.json" - - expect(response).to have_http_status(:not_found) - end - end - - describe "POST #import" do - let(:import_attributes) do - { - name: "Imported Tool", - tool_name: "imported_tool", - description: "An imported test tool", - parameters: [{ name: "query", type: "string", description: "perform a search" }], - script: "function invoke(params) { return params; }", - summary: "Imported tool summary", - } - end - - it "imports a new AI tool successfully" do - expect { - post "/admin/plugins/discourse-ai/ai-tools/import.json", - params: { ai_tool: import_attributes }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - }.to change(AiTool, :count).by(1) - - expect(response).to have_http_status(:created) - expect(response.parsed_body["ai_tool"]["name"]).to eq("Imported Tool") - expect(response.parsed_body["ai_tool"]["tool_name"]).to eq("imported_tool") - end - - it "returns conflict error when tool with same tool_name exists without force" do - _existing_tool = - AiTool.create!( - name: "Existing Tool", - tool_name: "imported_tool", - description: "Existing tool", - script: "function invoke(params) { return 'existing'; }", - summary: "Existing summary", - created_by_id: admin.id, - ) - - expect { - post "/admin/plugins/discourse-ai/ai-tools/import.json", - params: { ai_tool: import_attributes }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - }.not_to change(AiTool, :count) - - expect(response).to have_http_status(:conflict) - expect(response.parsed_body["errors"]).to include( - "Tool with tool_name 'imported_tool' already exists. Use force=true to overwrite.", - ) - end - - it "force updates existing tool when force=true" do - existing_tool = - AiTool.create!( - name: "Existing Tool", - tool_name: "imported_tool", - description: "Existing tool", - script: "function invoke(params) { return 'existing'; }", - summary: "Existing summary", - created_by_id: admin.id, - ) - - expect { - post "/admin/plugins/discourse-ai/ai-tools/import.json?force=true", - params: { ai_tool: import_attributes }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - }.not_to change(AiTool, :count) - - expect(response).to have_http_status(:ok) - expect(response.parsed_body["ai_tool"]["name"]).to eq("Imported Tool") - expect(response.parsed_body["ai_tool"]["description"]).to eq("An imported test tool") - - existing_tool.reload - expect(existing_tool.name).to eq("Imported Tool") - expect(existing_tool.description).to eq("An imported test tool") - end - end - - describe "POST #create" do - let(:valid_attributes) do - { - name: "Test Tool 1", - tool_name: "test_tool_1", - description: "A test tool", - parameters: [{ name: "query", type: "string", description: "perform a search" }], - script: "function invoke(params) { return params; }", - summary: "Test tool summary", - } - end - - it "creates a new AiTool" do - expect { - post "/admin/plugins/discourse-ai/ai-tools.json", - params: { ai_tool: valid_attributes }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - }.to change(AiTool, :count).by(1) - - expect(response).to have_http_status(:created) - expect(response.parsed_body["ai_tool"]["name"]).to eq("Test Tool 1") - expect(response.parsed_body["ai_tool"]["tool_name"]).to eq("test_tool_1") - end - - it "logs the creation with StaffActionLogger" do - expect { - post "/admin/plugins/discourse-ai/ai-tools.json", - params: { ai_tool: valid_attributes }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - }.to change { - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "create_ai_tool", - ).count - }.by(1) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "create_ai_tool", - ).last - expect(history.details).to include("name: Test Tool 1") - expect(history.details).to include("tool_name: test_tool_1") - expect(history.subject).to eq("Test Tool 1") # Verify subject field is included - end - - context "when the parameter is a enum" do - it "creates the tool with the correct parameters" do - attrs = valid_attributes - attrs[:parameters] = [attrs[:parameters].first.merge(enum: %w[c f])] - - expect { - post "/admin/plugins/discourse-ai/ai-tools.json", - params: { ai_tool: valid_attributes }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - }.to change(AiTool, :count).by(1) - - expect(response).to have_http_status(:created) - expect(response.parsed_body.dig("ai_tool", "parameters", 0, "enum")).to contain_exactly( - "c", - "f", - ) - end - end - - context "when enum validation fails" do - it "fails to create tool with empty enum" do - attrs = valid_attributes - attrs[:parameters] = [attrs[:parameters].first.merge(enum: [])] - - expect { - post "/admin/plugins/discourse-ai/ai-tools.json", - params: { ai_tool: attrs }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - }.not_to change(AiTool, :count) - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.parsed_body["errors"]).to include(match(/enum cannot be empty/)) - end - - it "fails to create tool with duplicate enum values" do - attrs = valid_attributes - attrs[:parameters] = [attrs[:parameters].first.merge(enum: %w[c f c])] - - expect { - post "/admin/plugins/discourse-ai/ai-tools.json", - params: { ai_tool: attrs }.to_json, - headers: { - "CONTENT_TYPE" => "application/json", - } - }.not_to change(AiTool, :count) - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.parsed_body["errors"]).to include(match(/enum values must be unique/)) - end - end - end - - describe "PUT #update" do - it "updates the requested ai_tool" do - put "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json", - params: { - ai_tool: { - name: "Updated Tool", - }, - } - - expect(response).to be_successful - expect(ai_tool.reload.name).to eq("Updated Tool") - end - - it "logs the update with StaffActionLogger" do - expect { - put "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json", - params: { - ai_tool: { - name: "Updated Tool", - description: "Updated description", - }, - } - }.to change { - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_tool", - ).count - }.by(1) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "update_ai_tool", - ).last - expect(history.details).to include("tool_id: #{ai_tool.id}") - expect(history.details).to include("name") - expect(history.details).to include("description") - expect(history.subject).to eq("Updated Tool") - end - - context "when updating an enum parameters" do - it "updates the enum fixed values" do - put "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json", - params: { - ai_tool: { - parameters: [ - { - name: "unit", - type: "string", - description: "the unit of measurement celcius c or fahrenheit f", - enum: %w[g d], - }, - ], - }, - } - - expect(response).to be_successful - expect(ai_tool.reload.parameters.dig(0, "enum")).to contain_exactly("g", "d") - end - end - end - - describe "DELETE #destroy" do - it "destroys the requested ai_tool" do - expect { delete "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json" }.to change( - AiTool, - :count, - ).by(-1) - - expect(response).to have_http_status(:no_content) - end - - it "logs the deletion with StaffActionLogger" do - tool_id = ai_tool.id - - expect { delete "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json" }.to change { - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "delete_ai_tool", - ).count - }.by(1) - - history = - UserHistory.where( - action: UserHistory.actions[:custom_staff], - custom_type: "delete_ai_tool", - ).last - expect(history.details).to include("tool_id: #{tool_id}") - expect(history.subject).to eq("Test Tool") # Verify subject field is included - end - end - - describe "#test" do - it "runs an existing tool and returns the result" do - post "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}/test.json", - params: { - parameters: { - input: "Hello, World!", - }, - } - - expect(response.status).to eq(200) - expect(response.parsed_body["output"]).to eq("input" => "Hello, World!") - end - - it "accept changes to the ai_tool parameters that redefine stuff" do - post "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}/test.json", - params: { - ai_tool: { - script: "function invoke(params) { return 'hi there'; }", - }, - parameters: { - input: "Hello, World!", - }, - } - - expect(response.status).to eq(200) - expect(response.parsed_body["output"]).to eq("hi there") - end - - it "returns an error for invalid tool_id" do - post "/admin/plugins/discourse-ai/ai-tools/-1/test.json", - params: { - parameters: { - input: "Hello, World!", - }, - } - - expect(response.status).to eq(404) - end - - it "handles exceptions during tool execution" do - ai_tool.update!(script: "function invoke(params) { throw new Error('Test error'); }") - - post "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}/test.json", - params: { - id: ai_tool.id, - parameters: { - input: "Hello, World!", - }, - } - - expect(response.status).to eq(400) - expect(response.parsed_body["errors"].to_s).to include("Error executing the tool") - end - end -end diff --git a/spec/requests/admin/ai_usage_controller_spec.rb b/spec/requests/admin/ai_usage_controller_spec.rb deleted file mode 100644 index 21003349..00000000 --- a/spec/requests/admin/ai_usage_controller_spec.rb +++ /dev/null @@ -1,181 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::Admin::AiUsageController do - fab!(:admin) - fab!(:user) - fab!(:llm_model) - let(:usage_report_path) { "/admin/plugins/discourse-ai/ai-usage-report.json" } - - before { enable_current_plugin } - - context "when logged in as admin" do - before { sign_in(admin) } - - describe "#show" do - fab!(:log1) do - AiApiAuditLog.create!( - provider_id: 1, - feature_name: "summarize", - language_model: "gpt-4", - request_tokens: 100, - response_tokens: 50, - created_at: 1.day.ago, - ) - end - - fab!(:log2) do - AiApiAuditLog.create!( - provider_id: 1, - feature_name: "translate", - language_model: "gpt-3.5", - request_tokens: 200, - response_tokens: 100, - created_at: 2.days.ago, - ) - end - - fab!(:log3) do - AiApiAuditLog.create!( - provider_id: 1, - feature_name: "ai_helper", - language_model: llm_model.name, - request_tokens: 300, - response_tokens: 150, - cached_tokens: 50, - created_at: 3.days.ago, - ) - end - - it "returns correct data structure" do - get usage_report_path - - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json).to have_key("data") - expect(json).to have_key("features") - expect(json).to have_key("models") - expect(json).to have_key("summary") - end - - it "respects date filters" do - get usage_report_path, - params: { - start_date: 3.days.ago.to_date, - end_date: 1.day.ago.to_date, - } - - json = response.parsed_body - expect(json["summary"]["total_tokens"]).to eq(900) # sum of all tokens - end - - it "filters by feature" do - get usage_report_path, params: { feature: "summarize" } - - json = response.parsed_body - - features = json["features"] - expect(features.length).to eq(1) - expect(features.first["feature_name"]).to eq("summarize") - expect(features.first["total_tokens"]).to eq(150) - end - - it "filters by model" do - get usage_report_path, params: { model: "gpt-3.5" } - - json = response.parsed_body - models = json["models"] - expect(models.length).to eq(1) - expect(models.first["llm"]).to eq("gpt-3.5") - expect(models.first["total_tokens"]).to eq(300) - end - - it "shows an estimated cost" do - get usage_report_path, params: { model: llm_model.name } - - json = response.parsed_body - summary = json["summary"] - feature = json["features"].find { |f| f["feature_name"] == "ai_helper" } - - expected_input_spending = llm_model.input_cost * log3.request_tokens / 1_000_000.0 - expected_cached_input_spending = - llm_model.cached_input_cost * log3.cached_tokens / 1_000_000.0 - expected_output_spending = llm_model.output_cost * log3.response_tokens / 1_000_000.0 - expected_total_spending = - expected_input_spending + expected_cached_input_spending + expected_output_spending - - expect(feature["input_spending"].to_s).to eq(expected_input_spending.to_s) - expect(feature["output_spending"].to_s).to eq(expected_output_spending.to_s) - expect(feature["cached_input_spending"].to_s).to eq(expected_cached_input_spending.to_s) - expect(summary["total_spending"].to_s).to eq(expected_total_spending.round(2).to_s) - end - - it "handles different period groupings" do - get usage_report_path, params: { period: "hour" } - expect(response.status).to eq(200) - - get usage_report_path, params: { period: "month" } - expect(response.status).to eq(200) - end - end - - # spec/requests/admin/ai_usage_controller_spec.rb - context "with hourly data" do - before do - freeze_time Time.parse("2021-02-01 00:00:00") - # Create data points across different hours - [23.hours.ago, 22.hours.ago, 21.hours.ago, 20.hours.ago].each do |time| - AiApiAuditLog.create!( - provider_id: 1, - feature_name: "summarize", - language_model: "gpt-4", - request_tokens: 100, - response_tokens: 50, - created_at: time, - ) - end - end - - it "returns hourly data when period is day" do - get usage_report_path, - params: { - start_date: 1.day.ago.to_date, - end_date: Time.current.to_date, - } - - expect(response.status).to eq(200) - json = response.parsed_body - - expect(json["data"].length).to eq(4) - - data_by_hour = json["data"].index_by { |d| Time.parse(d["period"]).hour } - - expect(data_by_hour.keys.length).to eq(4) - expect(data_by_hour.first[1]["total_tokens"]).to eq(150) - end - end - end - - context "when not admin" do - before { sign_in(user) } - - it "blocks access" do - get usage_report_path - expect(response.status).to eq(404) - end - end - - context "when plugin disabled" do - before do - SiteSetting.discourse_ai_enabled = false - sign_in(admin) - end - - it "returns error" do - get usage_report_path - expect(response.status).to eq(404) - end - end -end diff --git a/spec/requests/admin/rag_document_fragments_controller_spec.rb b/spec/requests/admin/rag_document_fragments_controller_spec.rb deleted file mode 100644 index 190f3488..00000000 --- a/spec/requests/admin/rag_document_fragments_controller_spec.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Admin::RagDocumentFragmentsController do - fab!(:admin) - fab!(:ai_persona) - - fab!(:vector_def) { Fabricate(:embedding_definition) } - - before do - enable_current_plugin - sign_in(admin) - SiteSetting.ai_embeddings_selected_model = vector_def.id - SiteSetting.ai_embeddings_enabled = true - end - - after { @cleanup_files&.each(&:unlink) } - - describe "GET #indexing_status_check" do - it "works for AiPersona" do - get "/admin/plugins/discourse-ai/rag-document-fragments/files/status.json?target_type=AiPersona&target_id=#{ai_persona.id}" - - expect(response.parsed_body).to eq({}) - expect(response.status).to eq(200) - end - end - - describe "POST #upload_file" do - let :fake_image do - @cleanup_files ||= [] - tempfile = Tempfile.new(%w[test .png]) - tempfile.write("fake image") - tempfile.rewind - @cleanup_files << tempfile - tempfile - end - - it "works" do - post "/admin/plugins/discourse-ai/rag-document-fragments/files/upload.json", - params: { - file: Rack::Test::UploadedFile.new(file_from_fixtures("spec.txt", "md")), - } - - expect(response.status).to eq(200) - - upload = Upload.last - expect(upload.original_filename).to end_with("spec.txt") - end - - it "rejects PDF files if site setting is not enabled" do - SiteSetting.ai_rag_images_enabled = false - - post "/admin/plugins/discourse-ai/rag-document-fragments/files/upload.json", - params: { - file: Rack::Test::UploadedFile.new(fake_image), - } - - expect(response.status).to eq(400) - end - - it "allows image files if site setting is enabled" do - SiteSetting.ai_rag_images_enabled = true - - post "/admin/plugins/discourse-ai/rag-document-fragments/files/upload.json", - params: { - file: Rack::Test::UploadedFile.new(fake_image), - } - - upload = Upload.last - expect(upload.original_filename).to end_with(".png") - end - end -end diff --git a/spec/requests/admin/reviewable_controller_spec.rb b/spec/requests/admin/reviewable_controller_spec.rb deleted file mode 100644 index eee480db..00000000 --- a/spec/requests/admin/reviewable_controller_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe ReviewablesController do - fab!(:post1) { Fabricate(:post) } - fab!(:post2) { Fabricate(:post) } - fab!(:admin) - fab!(:llm_model) - - fab!(:reviewable) do - Reviewable.create!( - target: post1, - topic: post2.topic, - type: ReviewablePost, - created_by: admin, - status: Reviewable.statuses[:pending], - ) - end - - fab!(:reviewable2) do - Reviewable.create!( - target: post2, - topic: post2.topic, - type: ReviewablePost, - created_by: admin, - status: Reviewable.statuses[:pending], - ) - end - - fab!(:ai_spam_log_missed) do - AiSpamLog.create!(is_spam: false, post_id: post1.id, llm_model_id: llm_model.id) - end - - before { enable_current_plugin } - - # we amend the behavior with a custom filter so we need to confirm it works - it "properly applies custom filter" do - sign_in(admin) - - get '/review.json?additional_filters={"ai_spam_false_negative":true}' - expect(response.status).to eq(200) - - json = JSON.parse(response.body) - expect(json["reviewables"].length).to eq(1) - - get "/review.json" - expect(response.status).to eq(200) - json = JSON.parse(response.body) - expect(json["reviewables"].length).to eq(2) - end -end diff --git a/spec/requests/ai_bot/artifact_key_values_controller_spec.rb b/spec/requests/ai_bot/artifact_key_values_controller_spec.rb deleted file mode 100644 index e55bf913..00000000 --- a/spec/requests/ai_bot/artifact_key_values_controller_spec.rb +++ /dev/null @@ -1,436 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::ArtifactKeyValuesController do - fab!(:user) - fab!(:admin) - fab!(:other_user) { Fabricate(:user) } - fab!(:private_message_topic) { Fabricate(:private_message_topic, user: user) } - fab!(:private_message_post) { Fabricate(:post, topic: private_message_topic, user: user) } - fab!(:artifact) do - Fabricate(:ai_artifact, post: private_message_post, metadata: { public: true }) - end - fab!(:private_artifact) { Fabricate(:ai_artifact, post: private_message_post) } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - end - - describe "#index" do - fab!(:public_key_value) do - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: user, - key: "test_key", - value: "test_value", - public: true, - ) - end - - fab!(:private_key_value) do - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: user, - key: "private_key", - value: "private_value", - public: false, - ) - end - - fab!(:other_user_key_value) do - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: other_user, - key: "other_key", - value: "other_value", - public: true, - ) - end - - fab!(:admin_key_value) do - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: admin, - key: "admin_key", - value: "admin_value", - public: false, - ) - end - - context "when not logged in" do - it "returns only public key values" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json", - params: { - all_users: true, - } - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["key_values"].length).to eq(2) # public_key_value and other_user_key_value - expect(json["key_values"].map { |kv| kv["key"] }).to contain_exactly( - "test_key", - "other_key", - ) - expect(json["has_more"]).to eq(false) - expect(json["total_count"]).to eq(2) - - expect(json["key_values"].map { |kv| kv["user_id"] }).to contain_exactly( - user.id, - other_user.id, - ) - expect(json["users"].map { |u| u["id"] }).to contain_exactly(user.id, other_user.id) - end - - it "returns 404 for private artifact" do - get "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}.json" - expect(response.status).to eq(404) - end - - it "returns 404 for non-existent artifact" do - get "/discourse-ai/ai-bot/artifact-key-values/999999.json" - expect(response.status).to eq(404) - end - end - - context "when logged in as regular user" do - before { sign_in(user) } - - it "returns public key values and own private key values" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json", - params: { - all_users: true, - } - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["key_values"].length).to eq(3) # all key values - expect(json["key_values"].map { |kv| kv["key"] }).to contain_exactly( - "test_key", - "private_key", - "other_key", - ) - end - - it "filters by current user when all_users is not true" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json" - - expect(response.status).to eq(200) - json = response.parsed_body - # Should only show user's own key values when all_users is not explicitly true - user_key_values = json["key_values"].select { |kv| kv["user_id"] == user.id } - expect(user_key_values.length).to be > 0 - end - - it "shows all users' key values when all_users=true" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?all_users=true" - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["key_values"].length).to eq(3) - end - - it "filters by key when specified" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?key=test_key" - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["key_values"].length).to eq(1) - expect(json["key_values"].first["key"]).to eq("test_key") - end - - it "returns keys only when keys_only=true" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?keys_only=true" - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["key_values"]).to be_present - # The serializer should handle keys_only option - end - end - - context "when logged in as admin" do - before { sign_in(admin) } - - it "returns only my own keys by default" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json" - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["key_values"].length).to eq(1) - expect(json["key_values"].map { |kv| kv["key"] }).to contain_exactly("admin_key") - end - - it "can access private artifacts" do - get "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}.json" - expect(response.status).to eq(200) - end - end - - context "when paginating" do - before do - sign_in(user) - # Create more key values to test pagination - 15.times do |i| - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: user, - key: "key_#{i}", - value: "value_#{i}", - public: true, - ) - end - end - - it "paginates results correctly" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?per_page=5&page=1" - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["key_values"].length).to eq(5) - expect(json["has_more"]).to eq(true) - end - - it "respects per_page limit" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?per_page=200" - - expect(response.status).to eq(200) - json = response.parsed_body - # Should be limited to PER_PAGE_MAX (100) - expect(json["key_values"].length).to be <= 100 - end - - it "defaults to page 1 for invalid page numbers" do - get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?page=0" - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["key_values"]).to be_present - end - end - end - - describe "#destroy" do - fab!(:key_value_to_delete) do - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: user, - key: "delete_me", - value: "delete_value", - public: false, - ) - end - - fab!(:other_user_key_value_to_delete) do - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: other_user, - key: "other_delete_me", - value: "other_delete_value", - public: false, - ) - end - - context "when not logged in" do - it "returns 403 forbidden" do - delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/delete_me.json" - expect(response.status).to eq(403) - end - end - - context "when logged in" do - before { sign_in(user) } - - it "deletes own key value successfully" do - expect { - delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/delete_me.json" - expect(response.status).to eq(200) - }.to change { artifact.key_values.count }.by(-1) - - expect(artifact.key_values.find_by(key: "delete_me")).to be_nil - end - - it "returns 404 for non-existent key" do - delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/non_existent_key.json" - - expect(response.status).to eq(404) - json = response.parsed_body - expect(json["error"]).to eq("Key not found") - end - - it "returns 404 when trying to delete another user's key" do - delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/other_delete_me.json" - - expect(response.status).to eq(404) - json = response.parsed_body - expect(json["error"]).to eq("Key not found") - - # Verify the key still exists - expect(artifact.key_values.find_by(key: "other_delete_me")).to be_present - end - - it "returns 404 for non-existent artifact" do - delete "/discourse-ai/ai-bot/artifact-key-values/999999/delete_me.json" - - expect(response.status).to eq(404) - end - - it "returns 404 for private artifact user cannot see" do - topic = Fabricate(:private_message_topic, user: other_user) - private_post = Fabricate(:post, topic: topic) - private_artifact = Fabricate(:ai_artifact, post: private_post) - Fabricate( - :ai_artifact_key_value, - ai_artifact: private_artifact, - user: other_user, - key: "private_key", - ) - - delete "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}/private_key.json" - - expect(response.status).to eq(404) - end - - context "with URL encoding" do - fab!(:special_key_value) do - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: user, - key: "key with spaces & symbols!", - value: "special_value", - ) - end - - it "handles URL encoded keys correctly" do - expect { - delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json", - params: { - key: "key with spaces & symbols!", - } - expect(response.status).to eq(200) - }.to change { artifact.key_values.count }.by(-1) - - expect(artifact.key_values.find_by(key: "key with spaces & symbols!")).to be_nil - end - end - end - - context "when logged in as admin" do - before { sign_in(admin) } - - it "can only delete own keys, not other users' keys" do - # Even admins should only be able to delete their own keys in this context - delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/other_delete_me.json" - - expect(response.status).to eq(404) - json = response.parsed_body - expect(json["error"]).to eq("Key not found") - - # Verify the key still exists - expect(artifact.key_values.find_by(key: "other_delete_me")).to be_present - end - - it "can delete own keys" do - _admin_key = - Fabricate( - :ai_artifact_key_value, - ai_artifact: artifact, - user: admin, - key: "admin_key", - value: "admin_value", - ) - - expect { - delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/admin_key.json" - expect(response.status).to eq(200) - }.to change { artifact.key_values.count }.by(-1) - end - - it "can access private artifacts" do - _admin_key = - Fabricate( - :ai_artifact_key_value, - ai_artifact: private_artifact, - user: admin, - key: "private_admin_key", - value: "private_admin_value", - ) - - expect { - delete "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}/private_admin_key.json" - expect(response.status).to eq(200) - }.to change { private_artifact.key_values.count }.by(-1) - end - end - end - - describe "#set" do - let(:valid_params) do - { artifact_id: artifact.id, key: "new_key", value: "new_value", public: true } - end - - context "when not logged in" do - it "returns 403 forbidden" do - post "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json", params: valid_params - - expect(response.status).to eq(403) - end - end - - context "when logged in" do - before { sign_in(user) } - - it "creates a new key value successfully" do - expect { - post "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json", params: valid_params - expect(response.status).to eq(200) - }.to change { artifact.key_values.count }.by(1) - - json = response.parsed_body - key_value = json["ai_artifact_key_value"] - expect(key_value["key"]).to eq("new_key") - expect(key_value["value"]).to eq("new_value") - - key_value = artifact.key_values.last - expect(key_value.user).to eq(user) - end - - it "returns validation errors for invalid data" do - post "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json", - params: { - artifact_id: artifact.id, - key: "", # invalid empty key - value: "value", - } - - expect(response.status).to eq(422) - json = response.parsed_body - expect(json["errors"]).to be_present - end - - it "returns 404 for non-existent artifact" do - post "/discourse-ai/ai-bot/artifact-key-values/999999.json", params: valid_params - - expect(response.status).to eq(404) - end - - it "returns 404 for private artifact user cannot see" do - topic = Fabricate(:private_message_topic, user: other_user) - private_post = Fabricate(:post, topic: topic) - private_artifact = Fabricate(:ai_artifact, post: private_post) - - post "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}.json", - params: valid_params - - expect(response.status).to eq(404) - end - end - end -end diff --git a/spec/requests/ai_bot/artifacts_controller_spec.rb b/spec/requests/ai_bot/artifacts_controller_spec.rb deleted file mode 100644 index 0f2037e0..00000000 --- a/spec/requests/ai_bot/artifacts_controller_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::ArtifactsController do - fab!(:user) - fab!(:topic) { Fabricate(:private_message_topic, user: user) } - fab!(:post) { Fabricate(:post, user: user, topic: topic) } - fab!(:artifact) do - AiArtifact.create!( - user: user, - post: post, - name: "Test Artifact", - html: "
    Hello World
    ", - css: "div { color: blue; }", - js: "console.log('test');", - metadata: { - public: false, - }, - ) - end - - def parse_srcdoc(html) - Nokogiri.HTML5(html).at_css("iframe")["srcdoc"] - end - - before do - enable_current_plugin - SiteSetting.ai_artifact_security = "strict" - end - - describe "#show" do - it "returns 404 when discourse_ai is disabled" do - SiteSetting.discourse_ai_enabled = false - get "/discourse-ai/ai-bot/artifacts/#{artifact.id}" - expect(response.status).to eq(404) - end - - it "returns 404 when ai_artifact_security disables it" do - SiteSetting.ai_artifact_security = "disabled" - get "/discourse-ai/ai-bot/artifacts/#{artifact.id}" - expect(response.status).to eq(404) - end - - context "with private artifact" do - it "returns 404 when user cannot see the post" do - get "/discourse-ai/ai-bot/artifacts/#{artifact.id}" - expect(response.status).to eq(404) - end - - it "shows artifact when user can see the post" do - sign_in(user) - get "/discourse-ai/ai-bot/artifacts/#{artifact.id}" - expect(response.status).to eq(200) - untrusted_html = parse_srcdoc(response.body) - expect(untrusted_html).to include(artifact.html) - expect(untrusted_html).to include(artifact.css) - expect(untrusted_html).to include(artifact.js) - end - - it "can also find an artifact by its version" do - sign_in(user) - - version = artifact.create_new_version(html: "
    Was Updated
    ") - - get "/discourse-ai/ai-bot/artifacts/#{artifact.id}/#{version.version_number}" - expect(response.status).to eq(200) - untrusted_html = parse_srcdoc(response.body) - expect(untrusted_html).to include("Was Updated") - expect(untrusted_html).to include(artifact.css) - expect(untrusted_html).to include(artifact.js) - end - end - - context "with public artifact" do - before { artifact.update!(metadata: { public: true }) } - - it "shows artifact without authentication" do - get "/discourse-ai/ai-bot/artifacts/#{artifact.id}" - expect(response.status).to eq(200) - expect(parse_srcdoc(response.body)).to include(artifact.html) - end - end - - it "removes security headers and disables crawling" do - sign_in(user) - get "/discourse-ai/ai-bot/artifacts/#{artifact.id}" - expect(response.headers["X-Frame-Options"]).to eq(nil) - expect(response.headers["Content-Security-Policy"]).to include("unsafe-inline") - expect(response.headers["X-Robots-Tag"]).to eq("noindex") - end - end -end diff --git a/spec/requests/ai_bot/bot_controller_spec.rb b/spec/requests/ai_bot/bot_controller_spec.rb deleted file mode 100644 index 552caa8d..00000000 --- a/spec/requests/ai_bot/bot_controller_spec.rb +++ /dev/null @@ -1,238 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiBot::BotController do - fab!(:user) - fab!(:pm_topic) { Fabricate(:private_message_topic) } - fab!(:pm_post) { Fabricate(:post, topic: pm_topic) } - fab!(:pm_post2) { Fabricate(:post, topic: pm_topic) } - fab!(:pm_post3) { Fabricate(:post, topic: pm_topic) } - - before do - enable_current_plugin - sign_in(user) - end - - describe "#show_debug_info" do - before { SiteSetting.ai_bot_enabled = true } - - it "returns a 403 when the user cannot debug the AI bot conversation" do - get "/discourse-ai/ai-bot/post/#{pm_post.id}/show-debug-info" - expect(response.status).to eq(403) - end - - it "returns debug info if the user can debug the AI bot conversation" do - user = pm_topic.topic_allowed_users.first.user - sign_in(user) - - log1 = - AiApiAuditLog.create!( - provider_id: 1, - topic_id: pm_topic.id, - raw_request_payload: "request", - raw_response_payload: "response", - request_tokens: 1, - response_tokens: 2, - ) - - log2 = - AiApiAuditLog.create!( - post_id: pm_post.id, - provider_id: 1, - topic_id: pm_topic.id, - raw_request_payload: "request", - raw_response_payload: "response", - request_tokens: 1, - response_tokens: 2, - ) - - log3 = - AiApiAuditLog.create!( - post_id: pm_post2.id, - provider_id: 1, - topic_id: pm_topic.id, - raw_request_payload: "request", - raw_response_payload: "response", - request_tokens: 1, - response_tokens: 2, - ) - - Group.refresh_automatic_groups! - SiteSetting.ai_bot_debugging_allowed_groups = user.groups.first.id.to_s - - get "/discourse-ai/ai-bot/post/#{pm_post.id}/show-debug-info" - expect(response.status).to eq(200) - - expect(response.parsed_body["id"]).to eq(log2.id) - expect(response.parsed_body["next_log_id"]).to eq(log3.id) - expect(response.parsed_body["prev_log_id"]).to eq(log1.id) - expect(response.parsed_body["topic_id"]).to eq(pm_topic.id) - - expect(response.parsed_body["request_tokens"]).to eq(1) - expect(response.parsed_body["response_tokens"]).to eq(2) - expect(response.parsed_body["raw_request_payload"]).to eq("request") - expect(response.parsed_body["raw_response_payload"]).to eq("response") - - # return previous post if current has no debug info - get "/discourse-ai/ai-bot/post/#{pm_post3.id}/show-debug-info" - expect(response.status).to eq(200) - expect(response.parsed_body["request_tokens"]).to eq(1) - expect(response.parsed_body["response_tokens"]).to eq(2) - - # can return debug info by id as well - get "/discourse-ai/ai-bot/show-debug-info/#{log1.id}" - expect(response.status).to eq(200) - expect(response.parsed_body["id"]).to eq(log1.id) - end - end - - describe "#stop_streaming_response" do - let(:redis_stream_key) { "gpt_cancel:#{pm_post.id}" } - - before { Discourse.redis.setex(redis_stream_key, 60, 1) } - - it "returns a 403 when the user cannot see the PM" do - post "/discourse-ai/ai-bot/post/#{pm_post.id}/stop-streaming" - - expect(response.status).to eq(403) - end - - it "deletes the key using to track the streaming" do - sign_in(pm_topic.topic_allowed_users.first.user) - - post "/discourse-ai/ai-bot/post/#{pm_post.id}/stop-streaming" - - expect(response.status).to eq(200) - expect(Discourse.redis.get(redis_stream_key)).to be_nil - end - end - - describe "#show_bot_username" do - it "returns the username_lower of the selected bot" do - gpt_35_bot = Fabricate(:llm_model, name: "gpt-3.5-turbo") - - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_35_bot]) - - expected_username = - DiscourseAi::AiBot::EntryPoint.find_user_from_model("gpt-3.5-turbo").username_lower - - get "/discourse-ai/ai-bot/bot-username", params: { username: gpt_35_bot.name } - - expect(response.status).to eq(200) - expect(response.parsed_body["bot_username"]).to eq(expected_username) - end - end - - describe "#discover" do - before { SiteSetting.ai_bot_enabled = true } - - fab!(:group) - fab!(:ai_persona) { Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: 1) } - - context "when no persona is selected" do - it "returns a 403" do - get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" } - - expect(response.status).to eq(403) - end - end - - context "when the user doesn't have access to the persona" do - before { SiteSetting.ai_bot_discover_persona = ai_persona.id } - - it "returns a 403" do - get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" } - - expect(response.status).to eq(403) - end - end - - context "when the user is allowed to use discover" do - before do - SiteSetting.ai_bot_discover_persona = ai_persona.id - group.add(user) - end - - it "returns a 200 and queues a job to reply" do - expect { - get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" } - }.to change(Jobs::StreamDiscoverReply.jobs, :size).by(1) - - expect(response.status).to eq(200) - end - - it "retues a 400 if the query is missing" do - get "/discourse-ai/ai-bot/discover" - - expect(response.status).to eq(400) - end - end - end - - describe "#discover_continue_convo" do - before { SiteSetting.ai_bot_enabled = true } - fab!(:group) - fab!(:llm_model) - fab!(:ai_persona) do - persona = Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: llm_model.id) - persona.create_user! - persona - end - let(:query) { "What is Discourse?" } - let(:context) { "Discourse is an open-source discussion platform." } - - context "when the user is allowed to discover" do - before do - SiteSetting.ai_bot_discover_persona = ai_persona.id - group.add(user) - end - - it "returns a 200 and creates a private message topic" do - expect { - post "/discourse-ai/ai-bot/discover/continue-convo", - params: { - user_id: user.id, - query: query, - context: context, - } - }.to change(Topic, :count).by(1) - - expect(response.status).to eq(200) - expect(response.parsed_body["topic_id"]).to be_present - end - - it "returns invalid parameters if the user_id is missing" do - post "/discourse-ai/ai-bot/discover/continue-convo", - params: { - query: query, - context: context, - } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to include("user_id") - end - - it "returns invalid parameters if the query is missing" do - post "/discourse-ai/ai-bot/discover/continue-convo", - params: { - user_id: user.id, - context: context, - } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to include("query") - end - - it "returns invalid parameters if the context is missing" do - post "/discourse-ai/ai-bot/discover/continue-convo", - params: { - user_id: user.id, - query: query, - } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to include("context") - end - end - end -end diff --git a/spec/requests/ai_bot/shared_ai_conversations_controller_spec.rb b/spec/requests/ai_bot/shared_ai_conversations_controller_spec.rb deleted file mode 100644 index f4c91bec..00000000 --- a/spec/requests/ai_bot/shared_ai_conversations_controller_spec.rb +++ /dev/null @@ -1,377 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe DiscourseAi::AiBot::SharedAiConversationsController do - before do - enable_current_plugin - toggle_enabled_bots(bots: [claude_2]) - SiteSetting.ai_bot_enabled = true - SiteSetting.ai_bot_allowed_groups = "10" - SiteSetting.ai_bot_public_sharing_allowed_groups = "10" - end - - fab!(:claude_2) { Fabricate(:llm_model, name: "claude-2") } - - fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } - fab!(:topic) - fab!(:pm) { Fabricate(:private_message_topic) } - fab!(:user_pm) { Fabricate(:private_message_topic, recipient: user) } - - fab!(:bot_user) do - enable_current_plugin - toggle_enabled_bots(bots: [claude_2]) - SiteSetting.ai_bot_enabled = true - SiteSetting.ai_bot_allowed_groups = "10" - SiteSetting.ai_bot_public_sharing_allowed_groups = "10" - claude_2.reload.user - end - - fab!(:user_pm_share) do - pm_topic = Fabricate(:private_message_topic, user: user, recipient: bot_user) - # a different unknown user - Fabricate(:post, topic: pm_topic, user: user) - Fabricate(:post, topic: pm_topic, user: bot_user) - Fabricate(:post, topic: pm_topic, user: user) - pm_topic - end - - let(:path) { "/discourse-ai/ai-bot/shared-ai-conversations" } - let(:shared_conversation) { SharedAiConversation.share_conversation(user, user_pm_share) } - - def share_error(key) - I18n.t("discourse_ai.share_ai.errors.#{key}") - end - - describe "POST create" do - context "when logged in" do - before { sign_in(user) } - - it "denies creating a new shared conversation on public topics" do - post "#{path}.json", params: { topic_id: topic.id } - expect(response).not_to have_http_status(:success) - - expect(response.parsed_body["errors"]).to eq([share_error(:not_allowed)]) - expect(response.parsed_body["errors"].to_s).not_to include("Translation missing") - end - - it "denies creating a new shared conversation for a random PM" do - post "#{path}.json", params: { topic_id: pm.id } - expect(response).not_to have_http_status(:success) - - expect(response.parsed_body["errors"]).to eq([share_error(:not_allowed)]) - expect(response.parsed_body["errors"].to_s).not_to include("Translation missing") - end - - it "denies creating a shared conversation for my PMs not with bots" do - post "#{path}.json", params: { topic_id: user_pm.id } - expect(response).not_to have_http_status(:success) - expect(response.parsed_body["errors"]).to eq([share_error(:other_people_in_pm)]) - expect(response.parsed_body["errors"].to_s).not_to include("Translation missing") - end - - it "denies creating a shared conversation for my PMs with bots that also have other users" do - pm_topic = Fabricate(:private_message_topic, user: user, recipient: bot_user) - # a different unknown user - Fabricate(:post, topic: pm_topic) - post "#{path}.json", params: { topic_id: pm_topic.id } - expect(response).not_to have_http_status(:success) - - expect(response.parsed_body["errors"]).to eq([share_error(:other_content_in_pm)]) - expect(response.parsed_body["errors"].to_s).not_to include("Translation missing") - end - - it "allows creating a shared conversation for my PMs with bots only" do - post "#{path}.json", params: { topic_id: user_pm_share.id } - expect(response).to have_http_status(:success) - end - - context "when ai artifacts are in lax mode" do - before { SiteSetting.ai_artifact_security = "lax" } - - it "properly shares artifacts" do - first_post = user_pm_share.posts.first - - artifact_not_allowed = - AiArtifact.create!( - user: bot_user, - post: Fabricate(:private_message_post), - name: "test", - html: "
    test
    ", - ) - - artifact = - AiArtifact.create!( - user: bot_user, - post: first_post, - name: "test", - html: "
    test
    ", - ) - - # lets log out and see we can not access the artifacts - delete "/session/#{user.id}" - - get artifact.url - expect(response).to have_http_status(:not_found) - - get artifact_not_allowed.url - expect(response).to have_http_status(:not_found) - - sign_in(user) - - first_post.update!(raw: <<~RAW) - This is a post with an artifact - -
    -
    - RAW - - post "#{path}.json", params: { topic_id: user_pm_share.id } - expect(response).to have_http_status(:success) - - key = response.parsed_body["share_key"] - - get "#{path}/#{key}" - expect(response).to have_http_status(:success) - - expect(response.body).to include(artifact.url) - expect(response.body).to include(artifact_not_allowed.url) - - # lets log out and see we can not access the artifacts - delete "/session/#{user.id}" - - get artifact.url - expect(response).to have_http_status(:success) - - get artifact_not_allowed.url - expect(response).to have_http_status(:not_found) - - sign_in(user) - delete "#{path}/#{key}.json" - expect(response).to have_http_status(:success) - - # we can not longer see it... - delete "/session/#{user.id}" - get artifact.url - expect(response).to have_http_status(:not_found) - end - end - - context "when secure uploads are enabled" do - let(:upload_1) { Fabricate(:s3_image_upload, user: bot_user, secure: true) } - let(:upload_2) { Fabricate(:s3_image_upload, user: bot_user, secure: true) } - let(:post_with_upload_1) { Fabricate(:post, topic: user_pm_share, user: bot_user) } - let(:post_with_upload_2) { Fabricate(:post, topic: user_pm_share, user: bot_user) } - - before do - enable_secure_uploads - stub_s3_store - SiteSetting.secure_uploads_pm_only = true - FileStore::S3Store.any_instance.stubs(:update_upload_ACL).returns(true) - Jobs.run_immediately! - - upload_1.update!( - access_control_post: post_with_upload_1, - sha1: SecureRandom.hex(20), - original_sha1: upload_1.sha1, - ) - upload_2.update!( - access_control_post: post_with_upload_2, - sha1: SecureRandom.hex(20), - original_sha1: upload_2.sha1, - ) - post_with_upload_1.update!( - raw: "This is a post with a cool AI generated picture ![wow](#{upload_1.short_url})", - ) - post_with_upload_2.update!( - raw: - "Another post that has been birthed by AI with a picture ![meow](#{upload_2.short_url})", - ) - end - - it "marks all of those uploads as not secure when sharing the topic" do - post "#{path}.json", params: { topic_id: user_pm_share.id } - expect(response).to have_http_status(:success) - expect(upload_1.reload.secure).to eq(false) - expect(upload_2.reload.secure).to eq(false) - end - - it "rebakes any posts in the topic with uploads attached when sharing the topic so image urls become non-secure" do - post_1_cooked = post_with_upload_1.cooked - post_2_cooked = post_with_upload_2.cooked - - post "#{path}.json", params: { topic_id: user_pm_share.id } - expect(response).to have_http_status(:success) - - expect(post_with_upload_1.reload.cooked).not_to eq(post_1_cooked) - expect(post_with_upload_1.reload.cooked).not_to include("secure-uploads") - expect(post_with_upload_2.reload.cooked).not_to eq(post_2_cooked) - expect(post_with_upload_2.reload.cooked).not_to include("secure-uploads") - end - end - end - - context "when not logged in" do - it "requires login" do - post "#{path}.json", params: { topic_id: topic.id } - expect(response).not_to have_http_status(:success) - end - end - end - - describe "DELETE destroy" do - context "when logged in" do - before { sign_in(user) } - - it "deletes the shared conversation" do - delete "#{path}/#{shared_conversation.share_key}.json" - expect(response).to have_http_status(:success) - expect(SharedAiConversation.exists?(shared_conversation.id)).to be_falsey - end - - it "returns an error if the shared conversation is not found" do - delete "#{path}/123.json" - expect(response).not_to have_http_status(:success) - end - - context "when secure uploads are enabled" do - let(:upload_1) { Fabricate(:s3_image_upload, user: bot_user, secure: false) } - let(:upload_2) { Fabricate(:s3_image_upload, user: bot_user, secure: false) } - - before do - enable_secure_uploads - stub_s3_store - SiteSetting.secure_uploads_pm_only = true - FileStore::S3Store.any_instance.stubs(:update_upload_ACL).returns(true) - Jobs.run_immediately! - - upload_1.update!( - access_control_post: shared_conversation.target.posts.first, - sha1: SecureRandom.hex(20), - original_sha1: upload_1.sha1, - ) - upload_2.update!( - access_control_post: shared_conversation.target.posts.second, - sha1: SecureRandom.hex(20), - original_sha1: upload_2.sha1, - ) - shared_conversation.target.posts.first.update!( - raw: "This is a post with a cool AI generated picture ![wow](#{upload_1.short_url})", - ) - shared_conversation.target.posts.second.update!( - raw: - "Another post that has been birthed by AI with a picture ![meow](#{upload_2.short_url})", - ) - end - - it "marks all uploads in the PM back as secure when unsharing the conversation" do - delete "#{path}/#{shared_conversation.share_key}.json" - expect(response).to have_http_status(:success) - expect(upload_1.reload.secure).to eq(true) - expect(upload_2.reload.secure).to eq(true) - end - - it "rebakes any posts in the topic with uploads attached when sharing the topic so image urls become secure" do - post_1_cooked = shared_conversation.target.posts.first.cooked - post_2_cooked = shared_conversation.target.posts.second.cooked - - delete "#{path}/#{shared_conversation.share_key}.json" - expect(response).to have_http_status(:success) - - expect(shared_conversation.target.posts.first.reload.cooked).not_to eq(post_1_cooked) - expect(shared_conversation.target.posts.first.reload.cooked).to include("secure-uploads") - expect(shared_conversation.target.posts.second.reload.cooked).not_to eq(post_2_cooked) - expect(shared_conversation.target.posts.second.reload.cooked).to include("secure-uploads") - end - end - end - - context "when not logged in" do - it "requires login" do - delete "#{path}/#{shared_conversation.share_key}.json" - expect(response).not_to have_http_status(:success) - end - end - end - - describe "GET asset" do - let(:helper) { Class.new { extend DiscourseAi::AiBot::SharedAiConversationsHelper } } - - it "renders highlight js correctly" do - get helper.share_asset_url("highlight.js") - - expect(response).to be_successful - expect(response.headers["Content-Type"]).to eq("application/javascript; charset=utf-8") - - js = File.read(DiscourseAi.public_asset_path("ai-share/highlight.min.js")) - expect(response.body).to eq(js) - end - - it "renders css correctly" do - get helper.share_asset_url("share.css") - - expect(response).to be_successful - expect(response.headers["Content-Type"]).to eq("text/css; charset=utf-8") - - css = File.read(DiscourseAi.public_asset_path("ai-share/share.css")) - expect(response.body).to eq(css) - end - end - - describe "GET preview" do - it "denies preview from logged out users" do - get "#{path}/preview/#{user_pm_share.id}.json" - expect(response).not_to have_http_status(:success) - end - - context "when logged in" do - before { sign_in(user) } - - it "renders the shared conversation" do - get "#{path}/preview/#{user_pm_share.id}.json" - expect(response).to have_http_status(:success) - expect(response.parsed_body["llm_name"]).to eq("Claude-2") - expect(response.parsed_body["error"]).to eq(nil) - expect(response.parsed_body["share_key"]).to eq(nil) - expect(response.parsed_body["context"].length).to eq(3) - - shared_conversation - get "#{path}/preview/#{user_pm_share.id}.json" - - expect(response).to have_http_status(:success) - expect(response.parsed_body["share_key"]).to eq(shared_conversation.share_key) - - SiteSetting.ai_bot_public_sharing_allowed_groups = "" - get "#{path}/preview/#{user_pm_share.id}.json" - expect(response).not_to have_http_status(:success) - end - end - end - - describe "GET show" do - it "redirects to home page if site require login" do - SiteSetting.login_required = true - get "#{path}/#{shared_conversation.share_key}" - expect(response).to redirect_to("/login") - end - - it "renders the shared conversation" do - get "#{path}/#{shared_conversation.share_key}" - expect(response).to have_http_status(:success) - expect(response.headers["Cache-Control"]).to eq("max-age=60, public") - expect(response.headers["X-Robots-Tag"]).to eq("noindex") - expect(response.body).not_to include("Translation missing") - end - - it "is also able to render in json format" do - get "#{path}/#{shared_conversation.share_key}.json" - expect(response.parsed_body["llm_name"]).to eq("Claude-2") - expect(response.headers["X-Robots-Tag"]).to eq("noindex") - end - - it "returns an error if the shared conversation is not found" do - get "#{path}/123" - expect(response).to have_http_status(:not_found) - end - end -end diff --git a/spec/requests/ai_bot/topic_serialization_spec.rb b/spec/requests/ai_bot/topic_serialization_spec.rb deleted file mode 100644 index 8d111e5f..00000000 --- a/spec/requests/ai_bot/topic_serialization_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Bot Post Serializer" do - fab!(:current_user) { Fabricate(:user) } - fab!(:bot_user) { Fabricate(:user) } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - sign_in(current_user) - end - - describe "llm_name in post serializer" do - it "includes llm_name when custom field is set in a PM" do - pm_topic = Fabricate(:private_message_topic, user: current_user) - - # Create a bot post with the custom field set - bot_post = - Fabricate( - :post, - topic: pm_topic, - user: bot_user, - custom_fields: { - DiscourseAi::AiBot::POST_AI_LLM_NAME_FIELD => "bob", - }, - ) - - get "/t/#{pm_topic.id}.json" - expect(response.status).to eq(200) - - json = response.parsed_body - bot_post_data = json["post_stream"]["posts"].find { |p| p["id"] == bot_post.id } - - expect(bot_post_data).to have_key("llm_name") - expect(bot_post_data["llm_name"]).to eq("bob") - end - end -end diff --git a/spec/requests/ai_helper/assistant_controller_spec.rb b/spec/requests/ai_helper/assistant_controller_spec.rb deleted file mode 100644 index e9a4e064..00000000 --- a/spec/requests/ai_helper/assistant_controller_spec.rb +++ /dev/null @@ -1,495 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::AiHelper::AssistantController do - fab!(:newuser) - fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } - - before do - enable_current_plugin - assign_fake_provider_to(:ai_helper_model) - end - - describe "#stream_suggestion" do - before do - Jobs.run_immediately! - SiteSetting.composer_ai_helper_allowed_groups = Group::AUTO_GROUPS[:trust_level_0] - end - - it "is able to stream suggestions to helper" do - sign_in(user) - - my_post = Fabricate(:post) - - channel = nil - messages = - MessageBus.track_publish do - results = [["hello ", "world"]] - DiscourseAi::Completions::Llm.with_prepared_responses(results) do - post "/discourse-ai/ai-helper/stream_suggestion.json", - params: { - text: "hello wrld", - location: "helper", - client_id: "1234", - post_id: my_post.id, - custom_prompt: "Translate to Spanish", - mode: DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT, - } - - expect(response.status).to eq(200) - channel = response.parsed_body["progress_channel"] - end - end - - # we only have the channel after we make the request - # so we can not filter till now - messages = messages.select { |m| m.channel == channel } - expect(messages).not_to be_empty - - last_message = messages.last - expect(messages.all? { |m| m.client_ids == ["1234"] }).to eq(true) - expect(messages.all? { |m| m == last_message || !m.data[:done] }).to eq(true) - - expect(last_message.channel).to eq(channel) - expect(last_message.data[:result]).to eq("hello world") - expect(last_message.data[:done]).to eq(true) - end - - it "is able to stream suggestions to composer" do - sign_in(user) - channel = nil - messages = - MessageBus.track_publish do - results = [["hello ", "world"]] - DiscourseAi::Completions::Llm.with_prepared_responses(results) do - post "/discourse-ai/ai-helper/stream_suggestion.json", - params: { - text: "hello wrld", - location: "composer", - client_id: "1234", - mode: DiscourseAi::AiHelper::Assistant::PROOFREAD, - } - - expect(response.status).to eq(200) - channel = response.parsed_body["progress_channel"] - end - end - - # we only have the channel after we make the request - # so we can not filter till now - messages = messages.select { |m| m.channel == channel } - - last_message = messages.last - expect(messages.all? { |m| m.client_ids == ["1234"] }).to eq(true) - expect(messages.all? { |m| m == last_message || !m.data[:done] }).to eq(true) - - expect(last_message.channel).to eq(channel) - expect(last_message.data[:result]).to eq("hello world") - expect(last_message.data[:done]).to eq(true) - end - end - - describe "#suggest" do - let(:text_to_proofread) { "The rain in spain stays mainly in the plane." } - let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." } - let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } - - context "when not logged in" do - it "returns a 403 response" do - post "/discourse-ai/ai-helper/suggest", params: { text: text_to_proofread, mode: mode } - - expect(response.status).to eq(403) - end - end - - context "when logged in as an user without enough privileges" do - before do - sign_in(newuser) - SiteSetting.composer_ai_helper_allowed_groups = Group::AUTO_GROUPS[:staff] - end - - it "returns a 403 response" do - post "/discourse-ai/ai-helper/suggest", params: { text: text_to_proofread, mode: mode } - - expect(response.status).to eq(403) - end - end - - context "when logged in as an allowed user" do - before do - sign_in(user) - user.group_ids = [Group::AUTO_GROUPS[:trust_level_1]] - SiteSetting.composer_ai_helper_allowed_groups = Group::AUTO_GROUPS[:trust_level_1] - end - - it "returns a 400 if the helper mode is invalid" do - invalid_mode = "asd" - - post "/discourse-ai/ai-helper/suggest", - params: { - text: text_to_proofread, - mode: invalid_mode, - } - - expect(response.status).to eq(400) - end - - it "returns a 400 if the text is blank" do - post "/discourse-ai/ai-helper/suggest", params: { mode: mode } - - expect(response.status).to eq(400) - end - - it "returns a generic error when the completion call fails" do - DiscourseAi::Completions::Llm - .any_instance - .expects(:generate) - .raises(DiscourseAi::Completions::Endpoints::Base::CompletionFailed) - - post "/discourse-ai/ai-helper/suggest", params: { mode: mode, text: text_to_proofread } - - expect(response.status).to eq(502) - end - - it "returns a suggestion" do - expected_diff = - "

    The rain in Spain, spain stays mainly in the Planeplane.

    " - - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do - post "/discourse-ai/ai-helper/suggest", params: { mode: mode, text: text_to_proofread } - - expect(response.status).to eq(200) - expect(response.parsed_body["suggestions"].first).to eq(proofread_text) - expect(response.parsed_body["diff"]).to eq(expected_diff) - end - end - - it "uses custom instruction when using custom_prompt mode" do - translated_text = "Un usuario escribio esto" - expected_diff = - "

    Un usuario escribio estoA user wrote this

    " - - DiscourseAi::Completions::Llm.with_prepared_responses([translated_text]) do - post "/discourse-ai/ai-helper/suggest", - params: { - mode: DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT, - text: "A user wrote this", - custom_prompt: "Translate to Spanish", - } - - expect(response.status).to eq(200) - expect(response.parsed_body["suggestions"].first).to eq(translated_text) - expect(response.parsed_body["diff"]).to eq(expected_diff) - end - end - - it "prevents double render when mode is ILLUSTRATE_POST" do - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do - expect { - post "/discourse-ai/ai-helper/suggest", - params: { - mode: DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST, - text: text_to_proofread, - force_default_locale: true, - } - }.not_to raise_error - expect(response.status).to eq(200) - end - end - - context "when performing numerous requests" do - it "rate limits" do - RateLimiter.enable - rate_limit = described_class::RATE_LIMITS["default"] - amount = rate_limit[:amount] - - amount.times do - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do - post "/discourse-ai/ai-helper/suggest", - params: { - mode: mode, - text: text_to_proofread, - } - expect(response.status).to eq(200) - end - end - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do - post "/discourse-ai/ai-helper/suggest", params: { mode: mode, text: text_to_proofread } - expect(response.status).to eq(429) - end - end - end - end - end - - describe "#suggest_title" do - fab!(:topic) - fab!(:post_1) { Fabricate(:post, topic: topic, raw: "I love apples") } - fab!(:post_3) { Fabricate(:post, topic: topic, raw: "I love mangos") } - fab!(:post_2) { Fabricate(:post, topic: topic, raw: "I love bananas") } - - context "when logged in as an allowed user" do - before do - sign_in(user) - user.group_ids = [Group::AUTO_GROUPS[:trust_level_1]] - SiteSetting.composer_ai_helper_allowed_groups = Group::AUTO_GROUPS[:trust_level_1] - end - - context "when suggesting titles with a topic_id" do - let(:title_suggestions) do - { - output: [ - "What are your favourite fruits?", - "Love for fruits", - "Fruits are amazing", - "Favourite fruit list", - "Fruit share topic", - ], - } - end - let(:title_suggestions_array) do - [ - "What are your favourite fruits?", - "Love for fruits", - "Fruits are amazing", - "Favourite fruit list", - "Fruit share topic", - ] - end - - it "returns title suggestions based on all topic post context" do - DiscourseAi::Completions::Llm.with_prepared_responses([title_suggestions]) do - post "/discourse-ai/ai-helper/suggest_title", params: { topic_id: topic.id } - - expect(response.status).to eq(200) - expect(response.parsed_body["suggestions"]).to eq(title_suggestions_array) - end - end - end - - context "when suggesting titles with input text" do - let(:title_suggestions) do - { - output: [ - "Apples - the best fruit", - "Why apples are great", - "Apples are the best fruit", - "My love for apples", - "I love apples", - ], - } - end - let(:title_suggestions_array) do - [ - "Apples - the best fruit", - "Why apples are great", - "Apples are the best fruit", - "My love for apples", - "I love apples", - ] - end - it "returns title suggestions based on the input text" do - DiscourseAi::Completions::Llm.with_prepared_responses([title_suggestions]) do - post "/discourse-ai/ai-helper/suggest_title", params: { text: post_1.raw } - - expect(response.status).to eq(200) - expect(response.parsed_body["suggestions"]).to eq(title_suggestions_array) - end - end - end - end - end - - describe "#caption_image" do - let(:image) { plugin_file_from_fixtures("100x100.jpg") } - let(:upload) { UploadCreator.new(image, "image.jpg").create_for(Discourse.system_user.id) } - let(:image_url) { "#{Discourse.base_url}#{upload.url}" } - let(:caption) { "A picture of a cat sitting on a table" } - let(:caption_with_attrs) do - "A picture of a cat sitting on a table (#{I18n.t("discourse_ai.ai_helper.image_caption.attribution")})" - end - let(:bad_caption) { "A picture of a cat \nsitting on a |table|" } - - before { assign_fake_provider_to(:ai_helper_image_caption_model) } - - def request_caption(params, caption = "A picture of a cat sitting on a table") - DiscourseAi::Completions::Llm.with_prepared_responses([caption]) do - post "/discourse-ai/ai-helper/caption_image", params: params - - yield(response) - end - end - - context "when logged in as an allowed user" do - before do - sign_in(user) - - SiteSetting.composer_ai_helper_allowed_groups = Group::AUTO_GROUPS[:trust_level_1] - end - - it "returns the suggested caption for the image" do - request_caption({ image_url: image_url, image_url_type: "long_url" }) do |r| - expect(r.status).to eq(200) - expect(r.parsed_body["caption"]).to eq(caption_with_attrs) - end - end - - it "returns a cleaned up caption from the LLM" do - request_caption({ image_url: image_url, image_url_type: "long_url" }, bad_caption) do |r| - expect(r.status).to eq(200) - expect(r.parsed_body["caption"]).to eq(caption_with_attrs) - end - end - - it "truncates the caption from the LLM" do - request_caption({ image_url: image_url, image_url_type: "long_url" }, caption * 10) do |r| - expect(r.status).to eq(200) - expect(r.parsed_body["caption"].size).to be < caption.size * 10 - end - end - - context "when the image_url is a short_url" do - let(:image_url) { upload.short_url } - - it "returns the suggested caption for the image" do - request_caption({ image_url: image_url, image_url_type: "short_url" }) do |r| - expect(r.status).to eq(200) - expect(r.parsed_body["caption"]).to eq(caption_with_attrs) - end - end - end - - context "when the image_url is a short_path" do - let(:image_url) { "#{Discourse.base_url}#{upload.short_path}" } - - it "returns the suggested caption for the image" do - request_caption({ image_url: image_url, image_url_type: "short_path" }) do |r| - expect(r.status).to eq(200) - expect(r.parsed_body["caption"]).to eq(caption_with_attrs) - end - end - end - - it "returns a 502 error when the completion call fails" do - DiscourseAi::Completions::Llm.with_prepared_responses( - [DiscourseAi::Completions::Endpoints::Base::CompletionFailed.new], - ) do - post "/discourse-ai/ai-helper/caption_image", - params: { - image_url: image_url, - image_url_type: "long_url", - } - - expect(response.status).to eq(502) - end - end - - it "returns a 400 error when the image_url is blank" do - post "/discourse-ai/ai-helper/caption_image" - - expect(response.status).to eq(400) - end - - it "returns a 404 error if no upload is found" do - post "/discourse-ai/ai-helper/caption_image", - params: { - image_url: "http://blah.com/img.jpeg", - image_url_type: "long_url", - } - - expect(response.status).to eq(404) - end - - context "for secure uploads" do - fab!(:group) - fab!(:private_category) { Fabricate(:private_category, group: group) } - let(:image) { plugin_file_from_fixtures("100x100.jpg") } - let(:upload) { UploadCreator.new(image, "image.jpg").create_for(Discourse.system_user.id) } - let(:image_url) { "#{Discourse.base_url}/secure-uploads/#{upload.url}" } - - before do - Jobs.run_immediately! - - # this is done so the after_save callbacks for site settings to make - # UploadReference records works - @original_provider = SiteSetting.provider - SiteSetting.provider = SiteSettings::DbProvider.new(SiteSetting) - enable_current_plugin - setup_s3 - stub_s3_store - assign_fake_provider_to(:ai_helper_image_caption_model) - SiteSetting.secure_uploads = true - SiteSetting.composer_ai_helper_allowed_groups = Group::AUTO_GROUPS[:trust_level_1] - - Group.find(SiteSetting.composer_ai_helper_allowed_groups_map.first).add(user) - user.reload - - stub_request( - :get, - "http://s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/original/1X/#{upload.sha1}.#{upload.extension}", - ).to_return(status: 200, body: "", headers: {}) - end - after { SiteSetting.provider = @original_provider } - - it "returns a 403 error if the user cannot access the secure upload" do - # hosted-site plugin edge case, it enables embeddings - SiteSetting.ai_embeddings_enabled = false - - create_post( - title: "Secure upload post", - raw: "This is a new post ", - category: private_category, - user: Discourse.system_user, - ) - - post "/discourse-ai/ai-helper/caption_image", - params: { - image_url: image_url, - image_url_type: "long_url", - } - expect(response.status).to eq(403) - end - - it "returns a 200 message and caption if user can access the secure upload" do - group.add(user) - - request_caption({ image_url: image_url, image_url_type: "long_url" }) do |r| - expect(r.status).to eq(200) - expect(r.parsed_body["caption"]).to eq(caption_with_attrs) - end - end - - context "if the input URL is for a secure upload but not on the secure-uploads path" do - let(:image_url) { "#{Discourse.base_url}#{upload.url}" } - - it "creates a signed URL properly and makes the caption" do - group.add(user) - - request_caption({ image_url: image_url, image_url_type: "long_url" }) do |r| - expect(r.status).to eq(200) - expect(r.parsed_body["caption"]).to eq(caption_with_attrs) - end - end - end - end - - context "when performing numerous requests" do - it "rate limits" do - RateLimiter.enable - - rate_limit = described_class::RATE_LIMITS["caption_image"] - amount = rate_limit[:amount] - - amount.times do - request_caption({ image_url: image_url, image_url_type: "long_url" }) do |r| - expect(r.status).to eq(200) - end - end - - request_caption({ image_url: image_url, image_url_type: "long_url" }) do |r| - expect(r.status).to eq(429) - end - end - end - end - end -end diff --git a/spec/requests/discord/bot_controller_spec.rb b/spec/requests/discord/bot_controller_spec.rb deleted file mode 100644 index 7d152508..00000000 --- a/spec/requests/discord/bot_controller_spec.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe "DiscourseAi::Discord::BotController", type: :request do - let(:public_key) { "your_public_key_here" } - let(:signature) { "valid_signature" } - let(:timestamp) { Time.now.to_i.to_s } - let(:body) { { type: 1 }.to_json } - let(:headers) { { "X-Signature-Ed25519" => signature, "X-Signature-Timestamp" => timestamp } } - - before do - enable_current_plugin - SiteSetting.ai_discord_app_public_key = public_key - allow_any_instance_of(DiscourseAi::Discord::BotController).to receive( - :verify_request!, - ).and_return(true) - end - - describe "POST /discourse-ai/discord/interactions" do - context "when interaction type is 1 (PING)" do - it "responds with type 1" do - post "/discourse-ai/discord/interactions", params: body, headers: headers - expect(response).to have_http_status(:ok) - expect(JSON.parse(response.body)).to eq("type" => 1) - end - end - - context "when interaction type is not 1" do - let(:guild_id) { "1234567890" } - let(:interaction_body) do - { - type: 2, - guild_id: guild_id, - data: { - options: [{ value: "test query" }], - }, - token: "interaction_token", - }.to_json - end - - before do - allow(SiteSetting).to receive(:ai_discord_allowed_guilds_map).and_return([guild_id]) - end - - xit "enqueues a job to handle the interaction" do - expect { - post "/discourse-ai/discord/interactions", params: interaction_body, headers: headers - }.to have_enqueued_job(Jobs::StreamDiscordReply) - end - - it "responds with a deferred message" do - post "/discourse-ai/discord/interactions", params: interaction_body, headers: headers - expect(response).to have_http_status(:ok) - expect(JSON.parse(response.body)).to eq( - "type" => 5, - "data" => { - "content" => "Searching...", - }, - ) - end - end - end -end diff --git a/spec/requests/embeddings/embeddings_controller_spec.rb b/spec/requests/embeddings/embeddings_controller_spec.rb deleted file mode 100644 index 5b7dc233..00000000 --- a/spec/requests/embeddings/embeddings_controller_spec.rb +++ /dev/null @@ -1,139 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Embeddings::EmbeddingsController do - context "when performing a topic search" do - fab!(:vector_def) { Fabricate(:open_ai_embedding_def) } - - before do - enable_current_plugin - SiteSetting.min_search_term_length = 3 - SiteSetting.ai_embeddings_selected_model = vector_def.id - DiscourseAi::Embeddings::SemanticSearch.clear_cache_for("test") - SearchIndexer.enable - end - - fab!(:category) - fab!(:subcategory) { Fabricate(:category, parent_category_id: category.id) } - - fab!(:topic) - fab!(:post) { Fabricate(:post, topic: topic) } - - fab!(:topic_in_subcategory) { Fabricate(:topic, category: subcategory) } - fab!(:post_in_subcategory) { Fabricate(:post, topic: topic_in_subcategory) } - - def index(topic) - vector = DiscourseAi::Embeddings::Vector.instance - - stub_request(:post, "https://api.openai.com/v1/embeddings").to_return( - status: 200, - body: JSON.dump({ data: [{ embedding: [0.1] * 1536 }] }), - ) - - vector.generate_representation_from(topic) - end - - def stub_embedding(query) - embedding = [0.049382] * 1536 - - EmbeddingsGenerationStubs.openai_service( - vector_def.lookup_custom_param("model_name"), - query, - embedding, - ) - end - - def create_api_key(user) - key = ApiKey.create!(user: user) - ApiKeyScope.create!(resource: "discourse_ai", action: "search", api_key_id: key.id) - key - end - - it "is able to make API requests using a scoped API key" do - index(topic) - query = "test" - stub_embedding(query) - user = topic.user - - api_key = create_api_key(user) - - get "/discourse-ai/embeddings/semantic-search.json?q=#{query}&hyde=false", - headers: { - "Api-Key" => api_key.key, - "Api-Username" => user.username, - } - - expect(response.status).to eq(200) - expect(response.parsed_body["topics"].map { |t| t["id"] }).to contain_exactly(topic.id) - end - - context "when rate limiting is enabled" do - before { RateLimiter.enable } - - it "will rate limit correctly" do - stub_const(subject.class, :MAX_HYDE_SEARCHES_PER_MINUTE, 1) do - stub_const(subject.class, :MAX_SEARCHES_PER_MINUTE, 2) do - query = "test #{SecureRandom.hex}" - stub_embedding(query) - get "/discourse-ai/embeddings/semantic-search.json?q=#{query}&hyde=false" - expect(response.status).to eq(200) - - query = "test #{SecureRandom.hex}" - stub_embedding(query) - get "/discourse-ai/embeddings/semantic-search.json?q=#{query}&hyde=false" - expect(response.status).to eq(200) - - query = "test #{SecureRandom.hex}" - stub_embedding(query) - get "/discourse-ai/embeddings/semantic-search.json?q=#{query}&hyde=false" - expect(response.status).to eq(429) - end - end - end - end - - it "returns results correctly when performing a non Hyde search" do - index(topic) - index(topic_in_subcategory) - - query = "test" - stub_embedding(query) - - get "/discourse-ai/embeddings/semantic-search.json?q=#{query}&hyde=false" - - expect(response.status).to eq(200) - expect(response.parsed_body["topics"].map { |t| t["id"] }).to contain_exactly( - topic.id, - topic_in_subcategory.id, - ) - end - - it "is able to filter to a specific category (including sub categories)" do - index(topic) - index(topic_in_subcategory) - - query = "test category:#{category.slug}" - stub_embedding("test") - - get "/discourse-ai/embeddings/semantic-search.json?q=#{query}&hyde=false" - - expect(response.status).to eq(200) - expect(response.parsed_body["topics"].map { |t| t["id"] }).to eq([topic_in_subcategory.id]) - end - - it "doesn't skip HyDE if the hyde param is missing" do - assign_fake_provider_to(:ai_embeddings_semantic_search_hyde_model) - index(topic) - index(topic_in_subcategory) - - query = "test category:#{category.slug}" - stub_embedding("test") - - DiscourseAi::Completions::Llm.with_prepared_responses(["Hyde #{query}"]) do - get "/discourse-ai/embeddings/semantic-search.json?q=#{query}" - - expect(response.status).to eq(200) - expect(response.parsed_body["topics"].map { |t| t["id"] }).to eq([topic_in_subcategory.id]) - end - end - end -end diff --git a/spec/requests/sentiment/sentiment_controller_spec.rb b/spec/requests/sentiment/sentiment_controller_spec.rb deleted file mode 100644 index 2f4e98e6..00000000 --- a/spec/requests/sentiment/sentiment_controller_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Sentiment::SentimentController do - describe "#posts" do - fab!(:admin) - fab!(:category) - fab!(:topic) { Fabricate(:topic, category: category) } - fab!(:post) { Fabricate(:post, user: admin, topic: topic) } - fab!(:post_2) { Fabricate(:post, user: admin, topic: topic) } - fab!(:classification_result) { Fabricate(:classification_result, target: post) } - - before do - enable_current_plugin - SiteSetting.ai_sentiment_enabled = true - sign_in(admin) - end - - it "returns a posts based on params" do - post.reload - classification_result.reload - - get "/discourse-ai/sentiment/posts", - params: { - group_by: "category", - group_value: category.name, - threshold: 0.0, - } - - expect(response).to be_successful - - post_response = JSON.parse(response.body) - posts = post_response["posts"] - posts.each do |post| - expect(post).to have_key("sentiment") - expect(post["sentiment"]).to match(/positive|negative|neutral/) - end - end - end -end diff --git a/spec/requests/summarization/chat_summary_controller_spec.rb b/spec/requests/summarization/chat_summary_controller_spec.rb deleted file mode 100644 index 08ed32e8..00000000 --- a/spec/requests/summarization/chat_summary_controller_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Summarization::ChatSummaryController do - fab!(:current_user) { Fabricate(:user) } - fab!(:group) - - before do - enable_current_plugin - - group.add(current_user) - - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - SiteSetting.ai_custom_summarization_allowed_groups = group.id - - SiteSetting.chat_enabled = true - SiteSetting.chat_allowed_groups = group.id - sign_in(current_user) - end - - describe "#show" do - context "when the user is not allowed to join the channel" do - fab!(:channel) { Fabricate(:private_category_channel) } - - it "returns a 403" do - get "/discourse-ai/summarization/channels/#{channel.id}", params: { since: 6 } - - expect(response.status).to eq(403) - end - end - end -end diff --git a/spec/requests/summarization/summary_controller_spec.rb b/spec/requests/summarization/summary_controller_spec.rb deleted file mode 100644 index 782c9988..00000000 --- a/spec/requests/summarization/summary_controller_spec.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::Summarization::SummaryController do - describe "#summary" do - fab!(:topic) { Fabricate(:topic, highest_post_number: 2) } - fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) } - fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) } - - before do - enable_current_plugin - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - end - - context "when streaming" do - it "return a cached summary with json payload and does not trigger job if it exists" do - summary = Fabricate(:ai_summary, target: topic) - sign_in(Fabricate(:admin)) - - get "/discourse-ai/summarization/t/#{topic.id}.json?stream=true" - - expect(response.status).to eq(200) - expect(Jobs::StreamTopicAiSummary.jobs.size).to eq(0) - - response_summary = response.parsed_body - expect(response_summary.dig("ai_topic_summary", "summarized_text")).to eq( - summary.summarized_text, - ) - end - end - - context "for anons" do - it "returns a 404 if there is no cached summary" do - get "/discourse-ai/summarization/t/#{topic.id}.json" - - expect(response.status).to eq(404) - end - - it "returns a cached summary" do - summary = Fabricate(:ai_summary, target: topic) - get "/discourse-ai/summarization/t/#{topic.id}.json" - - expect(response.status).to eq(200) - - response_summary = response.parsed_body - expect(response_summary.dig("ai_topic_summary", "summarized_text")).to eq( - summary.summarized_text, - ) - end - end - - context "when the user is a member of an allowlisted group" do - fab!(:user) { Fabricate(:leader) } - - before do - sign_in(user) - Group.find(Group::AUTO_GROUPS[:trust_level_3]).add(user) - end - - it "returns a 404 if there is no topic" do - invalid_topic_id = 999 - - get "/discourse-ai/summarization/t/#{invalid_topic_id}.json" - - expect(response.status).to eq(404) - end - - it "returns a 403 if not allowed to see the topic" do - pm = Fabricate(:private_message_topic) - - get "/discourse-ai/summarization/t/#{pm.id}.json" - - expect(response.status).to eq(403) - end - - it "returns a summary" do - summary_text = "This is a summary" - - DiscourseAi::Completions::Llm.with_prepared_responses([summary_text]) do - get "/discourse-ai/summarization/t/#{topic.id}.json" - - expect(response.status).to eq(200) - response_summary = response.parsed_body["ai_topic_summary"] - summary = AiSummary.last - - expect(summary.summarized_text).to eq(summary_text) - expect(response_summary["summarized_text"]).to eq(summary.summarized_text) - expect(response_summary["algorithm"]).to eq("fake") - expect(response_summary["outdated"]).to eq(false) - expect(response_summary["can_regenerate"]).to eq(true) - expect(response_summary["new_posts_since_summary"]).to be_zero - end - end - - it "signals the summary is outdated" do - get "/discourse-ai/summarization/t/#{topic.id}.json" - - Fabricate(:post, topic: topic, post_number: 3) - topic.update!(highest_post_number: 3) - - get "/discourse-ai/summarization/t/#{topic.id}.json" - expect(response.status).to eq(200) - summary = response.parsed_body["ai_topic_summary"] - - expect(summary["outdated"]).to eq(true) - expect(summary["new_posts_since_summary"]).to eq(1) - end - end - - context "when the user is not a member of an allowlisted group" do - fab!(:user) - - before { sign_in(user) } - - it "return a 404 if there is no cached summary" do - get "/discourse-ai/summarization/t/#{topic.id}.json" - - expect(response.status).to eq(404) - end - - it "returns a cached summary" do - summary = Fabricate(:ai_summary, target: topic) - - get "/discourse-ai/summarization/t/#{topic.id}.json" - - expect(response.status).to eq(200) - - response_summary = response.parsed_body - expect(response_summary.dig("ai_topic_summary", "summarized_text")).to eq( - summary.summarized_text, - ) - end - end - end -end diff --git a/spec/requests/topic_spec.rb b/spec/requests/topic_spec.rb deleted file mode 100644 index 85acb3ba..00000000 --- a/spec/requests/topic_spec.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -describe ::TopicsController do - fab!(:topic) - fab!(:topic1) { Fabricate(:topic) } - fab!(:topic2) { Fabricate(:topic) } - fab!(:topic3) { Fabricate(:topic) } - fab!(:user) { Fabricate(:admin) } - - before do - enable_current_plugin - - SiteSetting.ai_embeddings_semantic_related_topics_enabled = true - SiteSetting.ai_embeddings_semantic_related_topics = 2 - - DiscourseAi::Embeddings::SemanticRelated - .any_instance - .stubs(:related_topic_ids_for) - .returns([topic1.id, topic2.id, topic3.id]) - end - - context "when a user is logged on" do - it "includes related topics in payload when configured" do - get("#{topic.relative_url}.json") - expect(response.status).to eq(200) - json = response.parsed_body - - expect(json["suggested_topics"].length).to eq(0) - expect(json["related_topics"].length).to eq(2) - - sign_in(user) - - get("#{topic.relative_url}.json") - json = response.parsed_body - - expect(json["suggested_topics"].length).to eq(0) - expect(json["related_topics"].length).to eq(2) - end - - it "includes related topics in payload when configured" do - SiteSetting.lazy_load_categories_groups = "#{Group::AUTO_GROUPS[:everyone]}" - category = Fabricate(:category) - topic.update!(category: category) - - get("#{topic.relative_url}.json") - expect(response.status).to eq(200) - json = response.parsed_body - - expect(json["suggested_topics"].length).to eq(0) - expect(json["related_topics"].length).to eq(2) - expect(json["categories"].map { |c| c["id"] }).to contain_exactly( - SiteSetting.uncategorized_category_id, - category.id, - ) - end - end - - describe "crawler" do - let(:crawler_user_agent) do - "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" - end - - it "renders related topics in the crawler view" do - get topic.relative_url, env: { "HTTP_USER_AGENT" => crawler_user_agent } - body = response.body - expect(body).to have_tag(:div, with: { id: "related-topics" }) - expect(body).to have_tag(:tr, with: { id: "topic-list-item-#{topic1.id}" }) - expect(body).to have_tag(:tr, with: { id: "topic-list-item-#{topic2.id}" }) - end - end -end diff --git a/spec/serializers/ai_chat_channel_serializer_spec.rb b/spec/serializers/ai_chat_channel_serializer_spec.rb deleted file mode 100644 index a7f010fd..00000000 --- a/spec/serializers/ai_chat_channel_serializer_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe AiChatChannelSerializer do - fab!(:admin) - - before { enable_current_plugin } - - describe "#title" do - context "when the channel is a DM" do - fab!(:dm_channel) { Fabricate(:direct_message_channel) } - - it "display every participant" do - serialized = described_class.new(dm_channel, scope: Guardian.new(admin), root: nil) - - expect(serialized.title).to eq(dm_channel.title(nil)) - end - end - - context "when the channel is a regular one" do - fab!(:channel) { Fabricate(:chat_channel) } - - it "displays the category title" do - serialized = described_class.new(channel, scope: Guardian.new(admin), root: nil) - - expect(serialized.title).to eq(channel.title) - end - end - end -end diff --git a/spec/serializers/ai_features_persona_serializer_spec.rb b/spec/serializers/ai_features_persona_serializer_spec.rb deleted file mode 100644 index ea1438be..00000000 --- a/spec/serializers/ai_features_persona_serializer_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe AiFeaturesPersonaSerializer do - fab!(:admin) - fab!(:ai_persona) - fab!(:group) - fab!(:group_2) { Fabricate(:group) } - - before { enable_current_plugin } - - describe "serialized attributes" do - before do - ai_persona.allowed_group_ids = [group.id, group_2.id] - ai_persona.save! - end - - context "when there is a persona with allowed groups" do - let(:allowed_groups) do - Group - .where(id: ai_persona.allowed_group_ids) - .pluck(:id, :name) - .map { |id, name| { id: id, name: name } } - end - - it "display every participant" do - serialized = described_class.new(ai_persona, scope: Guardian.new(admin), root: nil) - expect(serialized.id).to eq(ai_persona.id) - expect(serialized.name).to eq(ai_persona.name) - expect(serialized.allowed_groups).to eq(allowed_groups) - end - end - end -end diff --git a/spec/services/discourse_ai/persona_exporter_spec.rb b/spec/services/discourse_ai/persona_exporter_spec.rb deleted file mode 100644 index 8b35cc56..00000000 --- a/spec/services/discourse_ai/persona_exporter_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::PersonaExporter do - before { enable_current_plugin } - - describe "#export" do - subject(:export_json) { JSON.parse(exporter.export) } - - context "when exporting a persona with a custom tool" do - fab!(:ai_tool) { Fabricate(:ai_tool, name: "Giphy Searcher", tool_name: "giphy_search") } - fab!(:ai_persona) { Fabricate(:ai_persona, tools: [["custom-#{ai_tool.id}", nil, false]]) } - - let(:exporter) { described_class.new(persona: ai_persona) } - - it "returns JSON containing the persona and its custom tool" do - expect(export_json["persona"]["name"]).to eq(ai_persona.name) - expect(export_json["persona"]["tools"].first.first).to eq("custom-#{ai_tool.tool_name}") - - custom_tool = export_json["custom_tools"].first - expect(custom_tool["identifier"]).to eq(ai_tool.tool_name) - expect(custom_tool["name"]).to eq(ai_tool.name) - end - end - - context "when the persona has no custom tools" do - fab!(:ai_persona) { Fabricate(:ai_persona, tools: []) } - let(:exporter) { described_class.new(persona: ai_persona) } - - it "returns JSON with an empty custom_tools array" do - expect(export_json["custom_tools"]).to eq([]) - end - end - - context "when the persona does not exist" do - it "raises an error if initialized with a non persona" do - expect { described_class.new(persona: nil) }.to raise_error( - ArgumentError, - "Invalid persona provided", - ) - end - end - end -end diff --git a/spec/services/discourse_ai/persona_importer_spec.rb b/spec/services/discourse_ai/persona_importer_spec.rb deleted file mode 100644 index 4c2c0cb5..00000000 --- a/spec/services/discourse_ai/persona_importer_spec.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DiscourseAi::PersonaImporter do - before { enable_current_plugin } - - describe "#import!" do - context "when importing a persona with a custom tool" do - fab!(:ai_tool) { Fabricate(:ai_tool, name: "Giphy Searcher", tool_name: "giphy_search") } - fab!(:ai_persona) { Fabricate(:ai_persona, tools: [["custom-#{ai_tool.id}", nil, false]]) } - - let!(:export_json) { DiscourseAi::PersonaExporter.new(persona: ai_persona).export } - - it "creates the persona and its custom tool" do - ai_persona.destroy - ai_tool.destroy - - importer = described_class.new(json: export_json) - persona = importer.import! - - expect(persona).to be_persisted - expect(persona.tools.first.first).to start_with("custom-") - - tool_id = persona.tools.first.first.split("-", 2).last.to_i - imported_tool = AiTool.find(tool_id) - - expect(imported_tool.tool_name).to eq("giphy_search") - expect(imported_tool.name).to eq("Giphy Searcher") - end - end - - context "when conflicts exist" do - fab!(:existing_tool) { Fabricate(:ai_tool, name: "Web Browser", tool_name: "browse_web") } - fab!(:another_tool) { Fabricate(:ai_tool, name: "Calculator", tool_name: "calculator") } - fab!(:ai_persona) do - Fabricate( - :ai_persona, - name: "Test Persona", - tools: [ - ["custom-#{existing_tool.id}", nil, false], - ["custom-#{another_tool.id}", nil, false], - ], - ) - end - - let(:export_json) { DiscourseAi::PersonaExporter.new(persona: ai_persona).export } - - context "when persona already exists" do - it "raises ImportError with persona conflict details" do - importer = described_class.new(json: export_json) - - expect { importer.import! }.to raise_error( - DiscourseAi::PersonaImporter::ImportError, - ) do |error| - expect(error.conflicts).to eq( - persona: "Test Persona", - custom_tools: %w[browse_web calculator], - ) - end - end - end - - context "when custom tools already exist" do - before { ai_persona.destroy } - - it "raises ImportError with custom tools conflict details" do - importer = described_class.new(json: export_json) - - expect { importer.import! }.to raise_error( - DiscourseAi::PersonaImporter::ImportError, - ) do |error| - expect(error.conflicts).to eq(custom_tools: %w[browse_web calculator]) - end - end - end - - context "when both persona and custom tools exist" do - it "raises ImportError with all conflicts" do - importer = described_class.new(json: export_json) - - expect { importer.import! }.to raise_error( - DiscourseAi::PersonaImporter::ImportError, - ) do |error| - expect(error.conflicts).to eq( - persona: "Test Persona", - custom_tools: %w[browse_web calculator], - ) - end - end - end - end - - context "with overwrite: true" do - fab!(:existing_tool) { Fabricate(:ai_tool, name: "Old Browser", tool_name: "browse_web") } - fab!(:existing_persona) do - Fabricate( - :ai_persona, - name: "Test Persona", - description: "Old description", - system_prompt: "Old prompt", - tools: [], - ) - end - fab!(:new_tool) { Fabricate(:ai_tool, name: "New Tool", tool_name: "new_tool") } - let(:export_persona) do - Fabricate.build( - :ai_persona, - name: "Test Persona", - description: "New description", - system_prompt: "New prompt", - tools: [ - ["custom-#{existing_tool.id}", nil, false], - ["custom-#{new_tool.id}", nil, false], - ], - ) - end - - let(:export_json) { DiscourseAi::PersonaExporter.new(persona: export_persona).export } - - before { export_persona.destroy } - - it "overwrites existing persona" do - importer = described_class.new(json: export_json) - persona = importer.import!(overwrite: true) - - expect(persona.id).to eq(existing_persona.id) - expect(persona.description).to eq("New description") - expect(persona.system_prompt).to eq("New prompt") - expect(persona.tools.length).to eq(2) - end - - it "overwrites existing custom tools" do - existing_tool.update!(name: "Old Browser", description: "Old description") - - importer = described_class.new(json: export_json) - expect { importer.import!(overwrite: true) }.not_to change { AiTool.count } - - existing_tool.reload - expect(existing_tool.name).to eq("Old Browser") # Name from export_json - end - end - - context "with invalid payload" do - it "raises an error for invalid JSON structure" do - expect { described_class.new(json: "{}").import! }.to raise_error( - ArgumentError, - "Invalid persona export data", - ) - end - - it "raises an error for missing persona data" do - expect { described_class.new(json: { "custom_tools" => [] }).import! }.to raise_error( - ArgumentError, - "Invalid persona export data", - ) - end - end - end -end diff --git a/spec/services/discourse_ai/topic_summarization_spec.rb b/spec/services/discourse_ai/topic_summarization_spec.rb deleted file mode 100644 index b8a77bb1..00000000 --- a/spec/services/discourse_ai/topic_summarization_spec.rb +++ /dev/null @@ -1,133 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::TopicSummarization do - fab!(:user) { Fabricate(:admin) } - fab!(:topic) { Fabricate(:topic, highest_post_number: 2) } - fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) } - fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) } - - before do - enable_current_plugin - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - end - - let(:strategy) { DiscourseAi::Summarization.topic_summary(topic) } - - let(:summary) { "This is the final summary" } - - describe "#summarize" do - subject(:summarization) { described_class.new(strategy, user) } - - def assert_summary_is_cached(topic, summary_response) - cached_summary = - AiSummary.find_by(target: topic, summary_type: AiSummary.summary_types[:complete]) - - expect(cached_summary.highest_target_number).to eq(topic.highest_post_number) - expect(cached_summary.summarized_text).to eq(summary) - expect(cached_summary.original_content_sha).to be_present - expect(cached_summary.algorithm).to eq("fake") - end - - context "when the content was summarized in a single chunk" do - it "caches the summary" do - DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do - section = summarization.summarize - expect(section.summarized_text).to eq(summary) - assert_summary_is_cached(topic, summary) - end - end - - it "returns the cached version in subsequent calls" do - summarization.summarize - - cached_summary_text = "This is a cached summary" - AiSummary.find_by(target: topic, summary_type: AiSummary.summary_types[:complete]).update!( - summarized_text: cached_summary_text, - updated_at: 24.hours.ago, - ) - - summarization = described_class.new(strategy, user) - section = summarization.summarize - expect(section.summarized_text).to eq(cached_summary_text) - end - end - - describe "invalidating cached summaries" do - let(:cached_text) { "This is a cached summary" } - - def cached_summary - AiSummary.find_by(target: topic, summary_type: AiSummary.summary_types[:complete]) - end - - before do - # a bit tricky, but fold_content now caches an instance of LLM - # once it is cached with_prepared_responses will not work as expected - # since it is glued to the old llm instance - # so we create the cached summary totally independantly - DiscourseAi::Completions::Llm.with_prepared_responses([cached_text]) do - strategy = DiscourseAi::Summarization.topic_summary(topic) - described_class.new(strategy, user).summarize - end - - cached_summary.update!(summarized_text: cached_text, created_at: 24.hours.ago) - end - - context "when the user can requests new summaries" do - context "when there are no new posts" do - it "returns the cached summary" do - section = summarization.summarize - - expect(section.summarized_text).to eq(cached_text) - end - end - - context "when there are new posts" do - before { cached_summary.update!(original_content_sha: "outdated_sha") } - - it "returns a new summary" do - DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do - section = summarization.summarize - - expect(section.summarized_text).to eq(summary) - end - end - - context "when the cached summary is less than one hour old" do - before { cached_summary.update!(created_at: 30.minutes.ago) } - - it "returns the cached summary" do - cached_summary.update!(created_at: 30.minutes.ago) - - section = summarization.summarize - - expect(section.summarized_text).to eq(cached_text) - expect(section.outdated).to eq(true) - end - - it "returns a new summary if the skip_age_check flag is passed" do - DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do - section = summarization.summarize(skip_age_check: true) - - expect(section.summarized_text).to eq(summary) - end - end - end - end - end - end - - describe "stream partial updates" do - it "receives a blk that is passed to the underlying strategy and called with partial summaries" do - partial_result = +"" - - DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do - summarization.summarize { |partial_summary| partial_result << partial_summary } - end - - # In a real world example, this is removed in the returned AiSummary obj. - expect(partial_result.chomp("\"}")).to eq(summary) - end - end - end -end diff --git a/spec/services/problem_check/ai_llm_status_spec.rb b/spec/services/problem_check/ai_llm_status_spec.rb deleted file mode 100644 index 11ef7abc..00000000 --- a/spec/services/problem_check/ai_llm_status_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe ProblemCheck::AiLlmStatus do - subject(:check) { described_class.new } - - fab!(:llm_model) - - let(:post_url) { "https://api.openai.com/v1/chat/completions" } - let(:success_response) do - { - model: "gpt-4-turbo", - usage: { - max_prompt_tokens: 131_072, - }, - choices: [ - { message: { role: "assistant", content: "test" }, finish_reason: "stop", index: 0 }, - ], - }.to_json - end - - let(:error_response) do - { message: "API key error! Please check you have supplied the correct API key." }.to_json - end - - before do - stub_request(:post, post_url).to_return(status: 200, body: success_response, headers: {}) - SiteSetting.ai_summarization_model = "custom:#{llm_model.id}" - SiteSetting.ai_summarization_enabled = true - end - - describe "#call" do - it "does nothing if discourse-ai plugin disabled" do - SiteSetting.discourse_ai_enabled = false - expect(check).to be_chill_about_it - end - - context "with discourse-ai plugin enabled for the site" do - before { enable_current_plugin } - - it "returns a problem with an LLM model" do - stub_request(:post, post_url).to_return(status: 403, body: error_response, headers: {}) - message = - I18n.t( - "dashboard.problem.ai_llm_status", - { - model_name: llm_model.display_name, - url: "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}/edit", - }, - ) - - expect(described_class.new.call).to contain_exactly( - have_attributes( - identifier: "ai_llm_status", - target: llm_model.id, - priority: "high", - message: message, - details: { - model_id: llm_model.id, - model_name: llm_model.display_name, - url: "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}/edit", - error: JSON.parse(error_response)["message"], - }, - ), - ) - end - - it "does not return a problem if the LLM models are working" do - stub_request(:post, post_url).to_return(status: 200, body: success_response, headers: {}) - expect(check).to be_chill_about_it - end - end - end -end diff --git a/spec/shared/inference/openai_embeddings_spec.rb b/spec/shared/inference/openai_embeddings_spec.rb deleted file mode 100644 index 01135ba9..00000000 --- a/spec/shared/inference/openai_embeddings_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Inference::OpenAiEmbeddings do - let(:api_key) { "123456" } - let(:dimensions) { 1000 } - let(:model) { "text-embedding-ada-002" } - - before { enable_current_plugin } - - it "supports azure embeddings" do - azure_url = - "https://my-company.openai.azure.com/openai/deployments/embeddings-deployment/embeddings?api-version=2023-05-15" - - body_json = { - usage: { - prompt_tokens: 1, - total_tokens: 1, - }, - data: [{ object: "embedding", embedding: [0.0, 0.1] }], - }.to_json - - stub_request(:post, azure_url).with( - body: "{\"model\":\"text-embedding-ada-002\",\"input\":\"hello\"}", - headers: { - "Api-Key" => api_key, - "Content-Type" => "application/json", - }, - ).to_return(status: 200, body: body_json, headers: {}) - - result = - DiscourseAi::Inference::OpenAiEmbeddings.new(azure_url, api_key, model, nil).perform!("hello") - - expect(result).to eq([0.0, 0.1]) - end - - it "supports openai embeddings" do - url = "https://api.openai.com/v1/embeddings" - body_json = { - usage: { - prompt_tokens: 1, - total_tokens: 1, - }, - data: [{ object: "embedding", embedding: [0.0, 0.1] }], - }.to_json - - body = { model: model, input: "hello", dimensions: dimensions }.to_json - - stub_request(:post, url).with( - body: body, - headers: { - "Authorization" => "Bearer #{api_key}", - "Content-Type" => "application/json", - }, - ).to_return(status: 200, body: body_json, headers: {}) - - result = - DiscourseAi::Inference::OpenAiEmbeddings.new(url, api_key, model, dimensions).perform!( - "hello", - ) - - expect(result).to eq([0.0, 0.1]) - end -end diff --git a/spec/shared/inference/stability_generator_spec.rb b/spec/shared/inference/stability_generator_spec.rb deleted file mode 100644 index a8d334b8..00000000 --- a/spec/shared/inference/stability_generator_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -describe DiscourseAi::Inference::StabilityGenerator do - def gen(prompt) - DiscourseAi::Inference::StabilityGenerator.perform!(prompt) - end - - let :sd3_response do - { image: "BASE64", seed: 1 }.to_json - end - - before { enable_current_plugin } - - it "is able to generate sd3 images" do - SiteSetting.ai_stability_engine = "sd3" - SiteSetting.ai_stability_api_url = "http://www.a.b.c" - SiteSetting.ai_stability_api_key = "123" - - # webmock does not support multipart form data :( - stub_request(:post, "http://www.a.b.c/v2beta/stable-image/generate/sd3").with( - headers: { - "Accept" => "application/json", - "Authorization" => "Bearer 123", - "Content-Type" => "multipart/form-data", - "Host" => "www.a.b.c", - "User-Agent" => DiscourseAi::AiBot::USER_AGENT, - }, - ).to_return(status: 200, body: sd3_response, headers: {}) - - json = - DiscourseAi::Inference::StabilityGenerator.perform!( - "a cow", - aspect_ratio: "16:9", - image_count: 2, - ) - - expect(json).to eq(artifacts: [{ base64: "BASE64", seed: 1 }, { base64: "BASE64", seed: 1 }]) - end - - it "sets dimensions to 512x512 for non XL model" do - SiteSetting.ai_stability_engine = "stable-diffusion-v1-5" - SiteSetting.ai_stability_api_url = "http://www.a.b.c" - SiteSetting.ai_stability_api_key = "123" - - stub_request(:post, "http://www.a.b.c/v1/generation/stable-diffusion-v1-5/text-to-image") - .with do |request| - json = JSON.parse(request.body) - expect(json["text_prompts"][0]["text"]).to eq("a cow") - expect(json["width"]).to eq(512) - expect(json["height"]).to eq(512) - expect(request.headers["Authorization"]).to eq("Bearer 123") - expect(request.headers["Content-Type"]).to eq("application/json") - true - end - .to_return(status: 200, body: "{}", headers: {}) - - gen("a cow") - end - - it "sets dimensions to 1024x1024 for XL model" do - SiteSetting.ai_stability_engine = "stable-diffusion-xl-1024-v1-0" - SiteSetting.ai_stability_api_url = "http://www.a.b.c" - SiteSetting.ai_stability_api_key = "123" - stub_request( - :post, - "http://www.a.b.c/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image", - ) - .with do |request| - json = JSON.parse(request.body) - expect(json["text_prompts"][0]["text"]).to eq("a cow") - expect(json["width"]).to eq(1024) - expect(json["height"]).to eq(1024) - expect(request.headers["Authorization"]).to eq("Bearer 123") - expect(request.headers["Content-Type"]).to eq("application/json") - true - end - .to_return(status: 200, body: "{}", headers: {}) - - gen("a cow") - end -end diff --git a/spec/support/embeddings_generation_stubs.rb b/spec/support/embeddings_generation_stubs.rb deleted file mode 100644 index 50bed368..00000000 --- a/spec/support/embeddings_generation_stubs.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class EmbeddingsGenerationStubs - class << self - def hugging_face_service(string, embedding, result_status: 200) - WebMock - .stub_request(:post, "https://test.com/embeddings") - .with(body: JSON.dump({ inputs: string, truncate: true })) - .to_return(status: result_status, body: JSON.dump([embedding])) - end - - def openai_service(model, string, embedding, result_status: 200, extra_args: {}) - WebMock - .stub_request(:post, "https://api.openai.com/v1/embeddings") - .with(body: JSON.dump({ model: model, input: string }.merge(extra_args))) - .to_return(status: result_status, body: JSON.dump({ data: [{ embedding: embedding }] })) - end - - def gemini_service(api_key, string, embedding, result_status: 200) - WebMock - .stub_request( - :post, - "https://generativelanguage.googleapis.com/v1beta/models/embedding-001:embedContent\?key\=#{api_key}", - ) - .with(body: JSON.dump({ content: { parts: [{ text: string }] } })) - .to_return(status: result_status, body: JSON.dump({ embedding: { values: embedding } })) - end - - def cloudflare_service(string, embedding, result_status: 200) - WebMock - .stub_request(:post, "https://test.com/embeddings") - .with(body: JSON.dump({ text: [string] })) - .to_return(status: result_status, body: JSON.dump({ result: { data: [embedding] } })) - end - end -end diff --git a/spec/support/sentiment_inference_stubs.rb b/spec/support/sentiment_inference_stubs.rb deleted file mode 100644 index e739fc6e..00000000 --- a/spec/support/sentiment_inference_stubs.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -class SentimentInferenceStubs - class << self - def model_response(model) - case model - when "SamLowe/roberta-base-go_emotions" - [ - { score: 0.90261286, label: "anger" }, - { score: 0.04127813, label: "annoyance" }, - { score: 0.03183503, label: "neutral" }, - { score: 0.005037033, label: "disgust" }, - { score: 0.0031153716, label: "disapproval" }, - { score: 0.0019118421, label: "disappointment" }, - { score: 0.0015849728, label: "sadness" }, - { score: 0.0012343781, label: "curiosity" }, - { score: 0.0010682651, label: "amusement" }, - { score: 0.00100747, label: "confusion" }, - { score: 0.0010035422, label: "admiration" }, - { score: 0.0009957326, label: "approval" }, - { score: 0.0009726665, label: "surprise" }, - { score: 0.0007754773, label: "realization" }, - { score: 0.0006978541, label: "love" }, - { score: 0.00064793555, label: "fear" }, - { score: 0.0006454095, label: "optimism" }, - { score: 0.0005969062, label: "joy" }, - { score: 0.0005498958, label: "embarrassment" }, - { score: 0.00050068577, label: "excitement" }, - { score: 0.00047403979, label: "caring" }, - { score: 0.00038841428, label: "gratitude" }, - { score: 0.00034546282, label: "desire" }, - { score: 0.00023012784, label: "grief" }, - { score: 0.00018133638, label: "remorse" }, - { score: 0.00012511834, label: "nervousness" }, - { score: 0.00012079607, label: "pride" }, - { score: 0.000063159685, label: "relief" }, - ] - when "cardiffnlp/twitter-roberta-base-sentiment-latest" - [ - { score: 0.627579, label: "negative" }, - { score: 0.29482335, label: "neutral" }, - { score: 0.07759768, label: "positive" }, - ] - when "j-hartmann/emotion-english-distilroberta-base" - [ - { score: 0.7033674, label: "anger" }, - { score: 0.2701151, label: "disgust" }, - { score: 0.009492096, label: "sadness" }, - { score: 0.0080775, label: "neutral" }, - { score: 0.0049473303, label: "fear" }, - { score: 0.0023369535, label: "surprise" }, - { score: 0.001663634, label: "joy" }, - ] - else - [ - { score: 0.1, label: "label 1" }, - { score: 0.2, label: "label 2" }, - { score: 0.3, label: "label 3" }, - ] - end - end - - def stub_classification(post) - content = post.post_number == 1 ? "#{post.topic.title}\n#{post.raw}" : post.raw - - DiscourseAi::Sentiment::SentimentSiteSettingJsonSchema.values.each do |model_config| - WebMock - .stub_request(:post, model_config.endpoint) - .with(body: JSON.dump(inputs: content, truncate: true)) - .to_return(status: 200, body: JSON.dump(model_response(model_config.model_name))) - end - end - end -end diff --git a/spec/support/stable_diffusion_stubs.rb b/spec/support/stable_diffusion_stubs.rb deleted file mode 100644 index da32248c..00000000 --- a/spec/support/stable_diffusion_stubs.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -class StableDiffusionStubs - include RSpec::Matchers - - def stub_response(prompt, images) - artifacts = images.map { |i| { base64: i } } - - WebMock - .stub_request( - :post, - "https://api.stability.dev/v1/generation/#{SiteSetting.ai_stability_engine}/text-to-image", - ) - .with do |request| - json = JSON.parse(request.body, symbolize_names: true) - expect(json[:text_prompts][0][:text]).to eq(prompt) - true - end - .to_return(status: 200, body: { artifacts: artifacts }.to_json) - end -end diff --git a/spec/system/admin_ai_features_spec.rb b/spec/system/admin_ai_features_spec.rb deleted file mode 100644 index 6df6ad5e..00000000 --- a/spec/system/admin_ai_features_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Admin AI features configuration", type: :system, js: true do - fab!(:admin) - fab!(:llm_model) - fab!(:summarization_persona) { Fabricate(:ai_persona) } - fab!(:group_1) { Fabricate(:group) } - fab!(:group_2) { Fabricate(:group) } - let(:page_header) { PageObjects::Components::DPageHeader.new } - let(:form) { PageObjects::Components::FormKit.new("form") } - let(:ai_features_page) { PageObjects::Pages::AdminAiFeatures.new } - - before do - enable_current_plugin - summarization_persona.allowed_group_ids = [group_1.id, group_2.id] - summarization_persona.save! - assign_fake_provider_to(:ai_summarization_model) - SiteSetting.ai_summarization_enabled = true - SiteSetting.ai_summarization_persona = summarization_persona.id - sign_in(admin) - end - - it "lists all persona backed AI features separated by configured/unconfigured" do - ai_features_page.visit - ai_features_page.toggle_configured - - expect(ai_features_page).to have_listed_modules(1) - - ai_features_page.toggle_unconfigured - - # this changes as we add more AI features - expect(ai_features_page).to have_listed_modules(8) - end - - it "lists the persona used for the corresponding AI feature" do - ai_features_page.visit - - ai_features_page.toggle_configured - - expect(ai_features_page).to have_feature_persona("topic_summaries", summarization_persona.name) - end - - it "lists the groups allowed to use the AI feature" do - ai_features_page.visit - - ai_features_page.toggle_configured - - expect(ai_features_page).to have_feature_groups("topic_summaries", [group_1.name, group_2.name]) - end - - it "shows edit page with settings" do - ai_features_page.visit - - ai_features_page.click_edit_module("summarization") - - expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-features/1/edit") - - expect(page).to have_css(".setting") - end -end diff --git a/spec/system/admin_ai_persona_spec.rb b/spec/system/admin_ai_persona_spec.rb deleted file mode 100644 index 763081d9..00000000 --- a/spec/system/admin_ai_persona_spec.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Admin AI persona configuration", type: :system, js: true do - fab!(:admin) - let(:page_header) { PageObjects::Components::DPageHeader.new } - let(:form) { PageObjects::Components::FormKit.new("form") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - sign_in(admin) - end - - it "allows creation of a persona" do - visit "/admin/plugins/discourse-ai/ai-personas" - - expect(page_header).to be_visible - - find(".ai-persona-list-editor__new-button").click() - - expect(page_header).to be_hidden - - form.field("name").fill_in("Test Persona") - form.field("description").fill_in("I am a test persona") - form.field("system_prompt").fill_in("You are a helpful bot") - - tool_selector = PageObjects::Components::SelectKit.new("#control-tools .select-kit") - tool_selector.expand - tool_selector.select_row_by_value("Read") - tool_selector.select_row_by_value("ListCategories") - tool_selector.collapse - - tool_selector = PageObjects::Components::SelectKit.new("#control-forcedTools .select-kit") - tool_selector.expand - tool_selector.select_row_by_value("ListCategories") - tool_selector.select_row_by_value("Read") - tool_selector.collapse - - form.field("forced_tool_count").select(1) - - form.submit - - expect(page).not_to have_current_path("/admin/plugins/discourse-ai/ai-personas/new") - - persona_id = page.current_path.split("/")[-2].to_i - - persona = AiPersona.find(persona_id) - expect(persona.name).to eq("Test Persona") - expect(persona.description).to eq("I am a test persona") - expect(persona.system_prompt).to eq("You are a helpful bot") - expect(persona.forced_tool_count).to eq(1) - - expected_tools = [["Read", { "read_private" => nil }, true], ["ListCategories", {}, true]] - expect(persona.tools).to contain_exactly(*expected_tools) - - # lets also test upgrades here... particularly one options was deleted and another added - # this ensurse that we can still edit the tool correctly and all options are present - persona.update!(tools: [["Read", { "got_deleted" => true }]]) - - visit "/admin/plugins/discourse-ai/ai-personas/#{persona_id}/edit" - - expect(page).to have_selector("input[name='toolOptions.Read.read_private']") - expect(page).not_to have_selector("input[name='toolOptions.Read.got_deleted']") - end - - it "will not allow deletion or editing of system personas" do - visit "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::Personas::Persona.system_personas.values.first}/edit" - expect(page).not_to have_selector(".ai-persona-editor__delete") - expect(form.field("system_prompt")).to be_disabled - end - - it "will enable persona right away when you click on enable but does not save side effects" do - persona = Fabricate(:ai_persona, enabled: false) - - visit "/admin/plugins/discourse-ai/ai-personas/#{persona.id}/edit" - - form.field("name").fill_in("Test Persona 1") - form.field("enabled").toggle - - try_until_success { expect(persona.reload.enabled).to eq(true) } - - persona.reload - expect(persona.enabled).to eq(true) - expect(persona.name).not_to eq("Test Persona 1") - end - - it "enabling a persona doesn't reset other fields" do - persona = Fabricate(:ai_persona, enabled: false) - updated_name = "Update persona 1" - - visit "/admin/plugins/discourse-ai/ai-personas/#{persona.id}/edit" - - form.field("name").fill_in(updated_name) - form.field("enabled").toggle - - try_until_success { expect(persona.reload.enabled).to eq(true) } - - expect(form.field("name").value).to eq(updated_name) - end - - it "toggling a persona's priority doesn't reset other fields" do - persona = Fabricate(:ai_persona, priority: false) - updated_name = "Update persona 1" - - visit "/admin/plugins/discourse-ai/ai-personas/#{persona.id}/edit" - - form.field("name").fill_in(updated_name) - form.field("priority").toggle - - try_until_success { expect(persona.reload.priority).to eq(true) } - - expect(form.field("name").value).to eq(updated_name) - end - - it "can navigate the AI plugin with breadcrumbs" do - visit "/admin/plugins/discourse-ai/ai-personas" - expect(page).to have_css(".d-breadcrumbs") - expect(page).to have_css(".d-breadcrumbs__item", count: 4) - find(".d-breadcrumbs__item", text: I18n.t("admin_js.admin.plugins.title")).click - expect(page).to have_current_path("/admin/plugins") - end -end diff --git a/spec/system/admin_dashboard_spec.rb b/spec/system/admin_dashboard_spec.rb deleted file mode 100644 index fd770656..00000000 --- a/spec/system/admin_dashboard_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Admin dashboard", type: :system do - fab!(:admin) - - before { enable_current_plugin } - - xit "displays the sentiment dashboard" do - SiteSetting.ai_sentiment_enabled = true - sign_in(admin) - - visit "/admin" - find(".navigation-item.sentiment").click() - - expect(page).to have_css(".section.sentiment") - end - - xit "displays the emotion table with links" do - SiteSetting.ai_sentiment_enabled = true - sign_in(admin) - - visit "/admin" - find(".navigation-item.sentiment").click() - - expect(page).to have_css(".admin-report.emotion-love .cell.value.today-count a") - end -end diff --git a/spec/system/ai_artifact_key_value_api_spec.rb b/spec/system/ai_artifact_key_value_api_spec.rb deleted file mode 100644 index 8494d4f2..00000000 --- a/spec/system/ai_artifact_key_value_api_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Artifact Key-Value API", type: :system, js: true do - fab!(:user) - fab!(:private_message_topic) { Fabricate(:private_message_topic, user: user) } - fab!(:private_message_post) { Fabricate(:post, topic: private_message_topic, user: user) } - fab!(:artifact) do - Fabricate( - :ai_artifact, - post: private_message_post, - metadata: { - public: true, - }, - html: '
    Artifact Loaded
    ', - js: <<~JS, - const logElement = document.getElementById('log'); - - window.addEventListener('load', async function() { - try { - logElement.innerHTML = "TESTING KEY-VALUE API..."; - const log = []; - await window.discourseArtifact.set('test_key', 'test_value'); - log.push('Set operation completed'); - logElement.innerHTML = log.join('
    '); - - const value = await window.discourseArtifact.get('test_key'); - log.push('Got value:' + value); - - await window.discourseArtifact.delete('test_key'); - log.push('Delete operation completed'); - - const deletedValue = await window.discourseArtifact.get('test_key'); - log.push('Deleted value should be null:' + deletedValue); - - logElement.innerHTML = log.join('
    '); - logElement.setAttribute('data-test-complete', 'true'); - } catch (error) { - logElement.innerHTML = error.message; - logElement.setAttribute('data-test-error', 'true'); - } - }); - JS - ) - end - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - sign_in(user) - end - - it "provides working key-value API in artifact JavaScript" do - visit "/discourse-ai/ai-bot/artifacts/#{artifact.id}" - - within_frame(find("iframe")) do - expect(page).to have_selector("#log", wait: 2) - expect(page).to have_selector("#log[data-test-complete='true']", wait: 2) - expect(page).to have_no_selector("#log[data-test-error]") - end - - expect(artifact.key_values.find_by(key: "test_key", user: user)).to be_nil - end -end diff --git a/spec/system/ai_bot/ai_bot_helper_spec.rb b/spec/system/ai_bot/ai_bot_helper_spec.rb deleted file mode 100644 index 9fca99e3..00000000 --- a/spec/system/ai_bot/ai_bot_helper_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true -RSpec.describe "AI chat channel summarization", type: :system, js: true do - fab!(:user) - fab!(:group) { Fabricate(:group, visibility_level: Group.visibility_levels[:staff]) } - - fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") } - fab!(:gpt_3_5_turbo) { Fabricate(:llm_model, name: "gpt-3.5-turbo") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_4, gpt_3_5_turbo]) - SiteSetting.ai_bot_allowed_groups = group.id.to_s - sign_in(user) - end - - it "does not show AI button to users not in group" do - visit "/latest" - expect(page).not_to have_selector(".ai-bot-button") - end - - it "shows the AI bot button, which is clickable (even if group is hidden)" do - # test was authored with this in mind - SiteSetting.ai_bot_enable_dedicated_ux = false - group.add(user) - group.save - - allowed_persona = AiPersona.last - allowed_persona.update!(allowed_group_ids: [group.id], enabled: true) - - visit "/latest" - expect(page).to have_selector(".ai-bot-button") - find(".ai-bot-button").click - - find(".gpt-persona").click - expect(page).to have_css(".gpt-persona ul li", count: 1) - - find(".llm-selector").click - expect(page).to have_css(".llm-selector ul li", count: 2) - - expect(page).to have_selector(".d-editor-container") - - # lets disable bots but still allow 1 persona - allowed_persona.create_user! - allowed_persona.update!(default_llm_id: gpt_4.id) - - gpt_4.update!(enabled_chat_bot: false) - gpt_3_5_turbo.update!(enabled_chat_bot: false) - - visit "/latest" - find(".ai-bot-button").click - - find(".gpt-persona").click - expect(page).to have_css(".gpt-persona ul li", count: 1) - expect(page).not_to have_selector(".llm-selector") - - SiteSetting.ai_bot_add_to_header = false - visit "/latest" - expect(page).not_to have_selector(".ai-bot-button") - end -end diff --git a/spec/system/ai_bot/ai_share_conversation_spec.rb b/spec/system/ai_bot/ai_share_conversation_spec.rb deleted file mode 100644 index 9361ac27..00000000 --- a/spec/system/ai_bot/ai_share_conversation_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Share conversation via link", type: :system do - fab!(:admin) { Fabricate(:admin, username: "ai_sharer") } - fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_4]) - SiteSetting.ai_bot_public_sharing_allowed_groups = "1" # admin - Group.refresh_automatic_groups! - sign_in(admin) - end - - let(:pm) do - Fabricate( - :private_message_topic, - title: "This is my special PM", - user: admin, - topic_allowed_users: [Fabricate.build(:topic_allowed_user, user: admin)], - ) - end - - let!(:op) { Fabricate(:post, topic: pm, user: admin, raw: "test test test user reply") } - - it "does not show share button for my own PMs without bot" do - visit(pm.url) - expect(Guardian.new(admin).can_share_ai_bot_conversation?(pm)).to eq(false) - expect(page).not_to have_selector(".share-ai-conversation-button") - end -end diff --git a/spec/system/ai_bot/artifact_spec.rb b/spec/system/ai_bot/artifact_spec.rb deleted file mode 100644 index 24282ddf..00000000 --- a/spec/system/ai_bot/artifact_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Artifact with Data Attributes", type: :system do - fab!(:admin) - fab!(:user) - fab!(:author) { Fabricate(:user) } - fab!(:category) { Fabricate(:category, user: admin, read_restricted: false) } - fab!(:topic) { Fabricate(:topic, category: category, user: author) } - fab!(:post) { Fabricate(:post, topic: topic, user: author) } - - before { enable_current_plugin } - - it "correctly passes data attributes and user info to a public AI artifact embedded in a post" do - artifact_js = <<~JS - window.discourseArtifactReady.then(data => { - const displayElement = document.getElementById('data-display'); - if (displayElement) { - displayElement.innerText = JSON.stringify(data); - } - }).catch(err => { - const displayElement = document.getElementById('data-display'); - if (displayElement) { - displayElement.innerText = 'Error: ' + err.message; - } - console.error("Artifact JS Error:", err); - }); - JS - - ai_artifact = - Fabricate( - :ai_artifact, - user: author, - name: "Data Passing Test Artifact", - html: "
    Waiting for data...
    ", - js: artifact_js.strip, - metadata: { - public: true, - }, - ) - - raw_post_content = - "
    " - _post = Fabricate(:post, topic: topic, user: author, raw: raw_post_content) - - sign_in(user) - visit "/t/#{topic.slug}/#{topic.id}" - - find(".ai-artifact__click-to-run button").click - - artifact_element_selector = ".ai-artifact[data-ai-artifact-id='#{ai_artifact.id}']" - artifact_element = find(artifact_element_selector) - - expect(artifact_element).to have_css("iframe[data-custom-message='hello-from-post']") - expect(artifact_element).to have_css("iframe[data-post-author-id='#{author.id}']") - - # note: artifacts are within nested iframes for security reasons - iframe_element = artifact_element.find("iframe") - within_frame(iframe_element) do - inner_iframe = find("iframe") - within_frame(inner_iframe) do - data_selector = "#data-display" - expect(page).to have_selector(data_selector, text: /.+/) - expect(page).to have_no_selector(data_selector, text: "Waiting for data...") - expect(page).to have_no_selector(data_selector, text: "Error:") - - artifact_data_json = find(data_selector).text - artifact_data = JSON.parse(artifact_data_json) - - expect(artifact_data["customMessage"]).to eq("hello-from-post") - expect(artifact_data["postAuthorId"]).to eq(author.id.to_s) - expect(artifact_data["username"]).to eq(user.username) - end - end - end -end diff --git a/spec/system/ai_bot/homepage_spec.rb b/spec/system/ai_bot/homepage_spec.rb deleted file mode 100644 index 943dd158..00000000 --- a/spec/system/ai_bot/homepage_spec.rb +++ /dev/null @@ -1,456 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Bot - Homepage", type: :system do - let(:cdp) { PageObjects::CDP.new } - let(:topic_page) { PageObjects::Pages::Topic.new } - let(:composer) { PageObjects::Components::Composer.new } - let(:ai_pm_homepage) { PageObjects::Components::AiPmHomepage.new } - let(:header) { PageObjects::Pages::DiscourseAi::Header.new } - let(:sidebar) { PageObjects::Components::NavigationMenu::Sidebar.new } - let(:header_dropdown) { PageObjects::Components::NavigationMenu::HeaderDropdown.new } - let(:dialog) { PageObjects::Components::Dialog.new } - - fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } - fab!(:user_2) { Fabricate(:user, refresh_auto_groups: true) } - - fab!(:claude_2) do - Fabricate( - :llm_model, - provider: "anthropic", - url: "https://api.anthropic.com/v1/messages", - name: "claude-2", - display_name: "Claude 2", - ) - end - fab!(:claude_2_dup) do - Fabricate( - :llm_model, - provider: "anthropic", - url: "https://api.anthropic.com/v1/messages", - name: "claude-2", - display_name: "Duplicate", - ) - end - fab!(:bot_user) do - enable_current_plugin - toggle_enabled_bots(bots: [claude_2, claude_2_dup]) - SiteSetting.ai_bot_enabled = true - claude_2.reload.user - end - fab!(:bot) do - persona = - AiPersona - .find(DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::General]) - .class_instance - .new - DiscourseAi::Personas::Bot.as(bot_user, persona: persona) - end - - fab!(:pm) do - Fabricate( - :private_message_topic, - title: "This is my special PM", - user: user, - last_posted_at: Time.zone.now, - topic_allowed_users: [ - Fabricate.build(:topic_allowed_user, user: user), - Fabricate.build(:topic_allowed_user, user: bot_user), - ], - ) - end - fab!(:first_post) do - Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user") - end - fab!(:second_post) do - Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply") - end - fab!(:third_post) do - Fabricate( - :post, - topic: pm, - user: user, - post_number: 3, - raw: "This is a second reply by the user", - ) - end - fab!(:topic_user) { Fabricate(:topic_user, topic: pm, user: user) } - fab!(:topic_bot_user) { Fabricate(:topic_user, topic: pm, user: bot_user) } - - fab!(:persona) do - persona = - AiPersona.create!( - name: "Test Persona", - description: "A test persona", - allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], - enabled: true, - system_prompt: "You are a helpful bot", - ) - - persona.create_user! - persona.update!( - default_llm_id: claude_2.id, - allow_chat_channel_mentions: true, - allow_topic_mentions: true, - ) - persona - end - - before do - enable_current_plugin - - pm.custom_fields[DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD] = "t" - pm.save! - - SiteSetting.ai_bot_enable_dedicated_ux = true - SiteSetting.ai_bot_enabled = true - SiteSetting.navigation_menu = "sidebar" - Jobs.run_immediately! - SiteSetting.ai_bot_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" - sign_in(user) - end - - %w[enabled disabled].each do |value| - before { SiteSetting.glimmer_post_stream_mode = value } - - context "when glimmer_post_stream_mode=#{value}" do - context "when mobile", mobile: true do - it "allows navigating from AI conversation to regular topic, and loads new post stream" do - regular_topic = Fabricate(:topic) - regular_post = Fabricate(:post, topic: regular_topic) - - post_url = Topic.relative_url(regular_topic.id, regular_topic.slug) - post_with_link = - Fabricate( - :post, - topic: pm, - user: user, - post_number: 4, - raw: "This is a second reply by the user [link](#{post_url})", - ) - - topic_page.visit_topic(pm) - page.find( - "article[data-post-id='#{post_with_link.id}'] .cooked a[href='#{post_url}']", - ).click - - try_until_success do - expect(topic_page.current_topic).to eq(regular_topic) - expect(page).to have_css("article[data-post-id='#{regular_post.id}']") - end - end - end - - context "when `ai_bot_enable_dedicated_ux` is enabled" do - it "allows uploading files to a new conversation" do - ai_pm_homepage.visit - expect(ai_pm_homepage).to have_homepage - - file_path_1 = file_from_fixtures("logo.png", "images").path - file_path_2 = file_from_fixtures("logo.jpg", "images").path - - attach_file([file_path_1, file_path_2]) do - find(".ai-bot-upload-btn", visible: true).click - end - - expect(page).to have_css(".ai-bot-upload", count: 2) - - ai_pm_homepage.input.fill_in(with: "Here are two image attachments") - - responses = ["hello user", "topic title"] - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - ai_pm_homepage.submit - expect(topic_page).to have_content("Here are two image attachments") - expect(page).to have_css(".cooked img", count: 2) - end - - find(".ai-new-question-button").click - expect(ai_pm_homepage).to have_homepage - expect(page).to have_no_css(".ai-bot-upload") - end - - it "shows an error when trying to submit while uploads are in progress" do - ai_pm_homepage.visit - expect(ai_pm_homepage).to have_homepage - - file_path_1 = file_from_fixtures("logo.png", "images").path - file_path_2 = file_from_fixtures("logo.jpg", "images").path - - ai_pm_homepage.input.fill_in(with: "Some message to send to AI with uploads") - - cdp.with_slow_upload do - attach_file([file_path_1, file_path_2]) do - find(".ai-bot-upload-btn", visible: true).click - end - expect(page).to have_css(".ai-bot-upload--in-progress", count: 2) - - ai_pm_homepage.submit - expect(page).to have_content( - I18n.t("js.discourse_ai.ai_bot.conversations.uploads_in_progress"), - ) - end - end - - it "allows removing an upload before submission" do - ai_pm_homepage.visit - expect(ai_pm_homepage).to have_homepage - - file_path = file_from_fixtures("logo.png", "images").path - attach_file([file_path]) { find(".ai-bot-upload-btn", visible: true).click } - expect(page).to have_css(".ai-bot-upload", count: 1) - - find(".ai-bot-upload__remove").click - - expect(page).to have_no_css(".ai-bot-upload") - - ai_pm_homepage.input.fill_in(with: "Message without attachments") - - responses = ["hello user", "topic title"] - DiscourseAi::Completions::Llm.with_prepared_responses(responses) do - ai_pm_homepage.submit - expect(topic_page).to have_content("Message without attachments") - expect(page).to have_no_css(".cooked img") - end - end - - it "renders landing page on bot click" do - visit "/" - header.click_bot_button - expect(ai_pm_homepage).to have_homepage - expect(sidebar).to be_visible - end - - it "displays error when message is too short" do - visit "/" - header.click_bot_button - - ai_pm_homepage.input.fill_in(with: "a") - ai_pm_homepage.submit - expect(ai_pm_homepage).to have_too_short_dialog - dialog.click_yes - expect(composer).to be_closed - end - - it "hides default content in the sidebar" do - visit "/" - header.click_bot_button - - expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_no_tags_section - expect(sidebar).to have_no_section("categories") - expect(sidebar).to have_no_section("chat-dms") - expect(sidebar).to have_no_section("chat-channels") - expect(sidebar).to have_no_section("user-threads") - end - - it "shows the bot conversation in the sidebar" do - visit "/" - header.click_bot_button - - expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section("Today") - expect(sidebar).to have_section_link(pm.title) - end - - it "shows empty state when no PMs exist" do - pm.destroy! - - visit "/" - header.click_bot_button - - expect(page).to have_css(".sidebar-section .ai-bot-sidebar-empty-state", visible: true) - end - - it "doesn't show empty state when a PM exists" do - visit "/" - header.click_bot_button - - expect(page).to have_no_css(".sidebar-section .ai-bot-sidebar-empty-state") - end - - it "displays last_7_days label in the sidebar" do - pm.update!(last_posted_at: Time.zone.now - 5.days) - visit "/" - header.click_bot_button - - expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section("Last 7 days") - end - - it "displays last_30_days label in the sidebar" do - pm.update!(last_posted_at: Time.zone.now - 28.days) - visit "/" - header.click_bot_button - - expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section("Last 30 days") - end - - it "displays month and year label in the sidebar for older conversations" do - pm.update!(last_posted_at: "2024-04-10 15:39:11.406192000 +00:00") - visit "/" - header.click_bot_button - - expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section("2024-3") - end - - it "navigates to the bot conversation when clicked" do - visit "/" - header.click_bot_button - - expect(ai_pm_homepage).to have_homepage - ai_pm_homepage.click_fist_sidebar_conversation - expect(topic_page).to have_topic_title(pm.title) - end - - it "displays the shuffle icon when on homepage or bot PM" do - visit "/" - expect(header).to have_icon_in_bot_button(icon: "robot") - header.click_bot_button - - expect(header).to have_icon_in_bot_button(icon: "shuffle") - - # Go to a PM and assert that the icon is still shuffle - ai_pm_homepage.click_fist_sidebar_conversation - expect(header).to have_icon_in_bot_button(icon: "shuffle") - - # Go back home and assert that the icon is now robot again - header.click_bot_button - expect(header).to have_icon_in_bot_button(icon: "robot") - end - - it "displays 'new question' button on homepage and topic page" do - topic_page.visit_topic(pm) - expect(ai_pm_homepage).to have_new_question_button - - ai_pm_homepage.visit - expect(ai_pm_homepage).to have_new_question_button - end - - it "redirect to the homepage when 'new question' is clicked" do - topic_page.visit_topic(pm) - expect(sidebar).to be_visible - ai_pm_homepage.click_new_question_button - expect(ai_pm_homepage).to have_homepage - end - - it "can send a new message to the bot" do - topic_page.visit_topic(pm) - topic_page.click_reply_button - expect(composer).to be_opened - - composer.fill_in(with: "Hello bot replying to you") - composer.submit - expect(page).to have_content("Hello bot replying to you") - end - - it "does not render custom sidebar on non-authored bot pms" do - # Include user_2 in the PM by creating a new post and topic_allowed_user association - Fabricate(:post, topic: pm, user: user_2, post_number: 4) - Fabricate(:topic_allowed_user, topic: pm, user: user_2) - sign_in(user_2) - - topic_page.visit_topic(pm) - - expect(sidebar).to be_visible - expect(sidebar).to have_no_section("ai-conversations-history") - expect(sidebar).to have_no_css("button.ai-new-question-button") - end - - it "does not include non-authored bot pms in sidebar" do - # Include user_2 in the PM by creating a new post and topic_allowed_user association - Fabricate(:post, topic: pm, user: user_2, post_number: 4) - Fabricate(:topic_allowed_user, topic: pm, user: user_2) - sign_in(user_2) - visit "/" - header.click_bot_button - expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_no_section_link(pm.title) - end - - it "Allows choosing persona and LLM" do - ai_pm_homepage.visit - - ai_pm_homepage.llm_selector.expand - ai_pm_homepage.llm_selector.select_row_by_name(claude_2_dup.display_name) - ai_pm_homepage.llm_selector.collapse - - # confirm memory works for llm selection - ai_pm_homepage.visit - expect(ai_pm_homepage.llm_selector).to have_selected_name(claude_2_dup.display_name) - end - - it "does not render back to forum link" do - ai_pm_homepage.visit - expect(ai_pm_homepage).to have_no_sidebar_back_link - end - - context "with hamburger menu" do - before { SiteSetting.navigation_menu = "header dropdown" } - it "keeps robot icon in the header and doesn't display sidebar back link" do - visit "/" - expect(header).to have_icon_in_bot_button(icon: "robot") - header.click_bot_button - expect(ai_pm_homepage).to have_homepage - expect(header).to have_icon_in_bot_button(icon: "robot") - expect(ai_pm_homepage).to have_no_sidebar_back_link - end - - it "still renders the sidebar" do - visit "/" - header.click_bot_button - expect(ai_pm_homepage).to have_homepage - expect(sidebar).to be_visible - expect(header_dropdown).to be_visible - end - end - end - - context "when `ai_bot_enable_dedicated_ux` is disabled" do - before { SiteSetting.ai_bot_enable_dedicated_ux = false } - - it "opens composer on bot click" do - visit "/" - header.click_bot_button - - expect(ai_pm_homepage).to have_no_homepage - expect(composer).to be_opened - end - - it "does not render sidebar when navigation menu is set to header on pm" do - SiteSetting.navigation_menu = "header dropdown" - topic_page.visit_topic(pm) - - expect(ai_pm_homepage).to have_no_homepage - expect(sidebar).to be_not_visible - expect(header_dropdown).to be_visible - end - - it "shows default content in the sidebar" do - topic_page.visit_topic(pm) - - expect(sidebar).to have_section("categories") - expect(sidebar).to have_section("chat-dms") - expect(sidebar).to have_no_css("button.ai-new-question-button") - end - end - - context "with header dropdown on mobile", mobile: true do - before { SiteSetting.navigation_menu = "header dropdown" } - - it "displays the new question button in the menu when viewing a PM" do - ai_pm_homepage.visit - header_dropdown.open - expect(ai_pm_homepage).to have_new_question_button - - topic_page.visit_topic(pm) - header_dropdown.open - ai_pm_homepage.click_new_question_button - - # Hamburger sidebar is closed - expect(header_dropdown).to have_no_dropdown_visible - end - end - end - end -end diff --git a/spec/system/ai_bot/persona_spec.rb b/spec/system/ai_bot/persona_spec.rb deleted file mode 100644 index f9f85a84..00000000 --- a/spec/system/ai_bot/persona_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI personas", type: :system, js: true do - fab!(:admin) - fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_4]) - sign_in(admin) - end - - it "can select and save persona tool options" do - visit "/admin/plugins/discourse-ai/ai-personas" - find(".ai-persona-list-editor__new-button").click - - expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-personas/new") - - form = PageObjects::Components::FormKit.new("form") - form.field("name").fill_in("Test Persona") - form.field("description").fill_in("This is a test persona.") - form.field("system_prompt").fill_in("You are a helpful assistant.") - form.field("tools").select("Update Artifact") - form.field("toolOptions.UpdateArtifact.update_algorithm").select("full") - form.submit - - expect(page).to have_current_path(%r{/admin/plugins/discourse-ai/ai-personas/\d+/edit}) - - persona = AiPersona.order("id desc").first - - expect(persona.name).to eq("Test Persona") - expect(persona.description).to eq("This is a test persona.") - expect(persona.tools.count).to eq(1) - expect(persona.tools.first[0]).to eq("UpdateArtifact") - expect(persona.tools.first[1]["update_algorithm"]).to eq("full") - end - - it "remembers the last selected persona" do - visit "/" - find(".d-header .ai-bot-button").click() - persona_selector = - PageObjects::Components::SelectKit.new(".persona-llm-selector__persona-dropdown") - - id = DiscourseAi::Personas::Persona.all(user: admin).first.id - - expect(persona_selector).to have_selected_value(id) - - persona_selector.expand - persona_selector.select_row_by_value(-2) - - visit "/" - find(".d-header .ai-bot-button").click() - persona_selector = - PageObjects::Components::SelectKit.new(".persona-llm-selector__persona-dropdown") - persona_selector.expand - expect(persona_selector).to have_selected_value(-2) - end -end diff --git a/spec/system/ai_bot/share_spec.rb b/spec/system/ai_bot/share_spec.rb deleted file mode 100644 index 8a3852fd..00000000 --- a/spec/system/ai_bot/share_spec.rb +++ /dev/null @@ -1,157 +0,0 @@ -# frozen_string_literal: true -RSpec.describe "Share conversation", type: :system do - fab!(:admin) { Fabricate(:admin, username: "ai_sharer") } - fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") } - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model("gpt-4") } - - let(:pm) do - Fabricate( - :private_message_topic, - title: "This is my special PM", - user: admin, - topic_allowed_users: [ - Fabricate.build(:topic_allowed_user, user: admin), - Fabricate.build(:topic_allowed_user, user: bot_user), - ], - ) - end - - let(:pm_posts) do - posts = [] - i = 1 - 3.times do - posts << Fabricate(:post, topic: pm, user: admin, raw: "test test test user reply #{i}") - posts << Fabricate(:post, topic: pm, user: bot_user, raw: "test test test bot reply #{i}") - i += 1 - end - - posts - end - - let(:cdp) { PageObjects::CDP.new } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - toggle_enabled_bots(bots: [gpt_4]) - sign_in(admin) - - bot_user.update!(username: "gpt-4") - - Group.refresh_automatic_groups! - - cdp.allow_clipboard - page.execute_script("window.navigator.clipboard.writeText('')") - end - - it "can share a conversation with a persona user" do - clip_text = nil - - persona = Fabricate(:ai_persona, name: "Tester") - persona.create_user! - - Fabricate(:post, topic: pm, user: admin, raw: "How do I do stuff?") - Fabricate(:post, topic: pm, user: persona.user, raw: "No idea") - - visit(pm.url) - - find("#post_2 .post-action-menu__share-ai").click - - try_until_success do - clip_text = cdp.read_clipboard - expect(clip_text).not_to eq("") - end - - conversation = (<<~TEXT).strip -
    - - This is my special PM - AI - - - **ai_sharer:** - - How do I do stuff? - - **Tester_bot:** - - No idea -
    - TEXT - - expect(conversation).to eq(clip_text) - end - - it "can share a conversation" do - clip_text = nil - - pm - pm_posts - - visit(pm.url) - - find("#post_2 .post-action-menu__share-ai").click - - try_until_success do - clip_text = cdp.read_clipboard - expect(clip_text).not_to eq("") - end - - conversation = (<<~TEXT).strip -
    - - This is my special PM - AI - - - **ai_sharer:** - - test test test user reply 1 - - **gpt-4:** - - test test test bot reply 1 -
    - TEXT - - expect(conversation).to eq(clip_text) - - page.execute_script("window.navigator.clipboard.writeText('')") - - find("#post_6 .post-action-menu__share-ai").click - find(".ai-share-modal__slider input").set("2") - find(".ai-share-modal button.btn-primary").click - - try_until_success do - clip_text = cdp.read_clipboard - expect(clip_text).not_to eq("") - end - - conversation = (<<~TEXT).strip -
    - - This is my special PM - AI - - - **ai_sharer:** - - test test test user reply 2 - - **gpt-4:** - - test test test bot reply 2 - - **ai_sharer:** - - test test test user reply 3 - - **gpt-4:** - - test test test bot reply 3 -
    - TEXT - - expect(conversation).to eq(clip_text) - end -end diff --git a/spec/system/ai_bot/tool_spec.rb b/spec/system/ai_bot/tool_spec.rb deleted file mode 100644 index abb65590..00000000 --- a/spec/system/ai_bot/tool_spec.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -describe "AI Tool Management", type: :system do - fab!(:admin) - let(:page_header) { PageObjects::Components::DPageHeader.new } - - before do - enable_current_plugin - SiteSetting.ai_embeddings_enabled = true - sign_in(admin) - end - - def ensure_can_run_test - find(".ai-tool-editor__test-button").click - - modal = PageObjects::Modals::AiToolTest.new - modal.base_currency = "USD" - modal.target_currency = "EUR" - modal.amount = "100" - - stub_request(:get, %r{https://open\.er-api\.com/v6/latest/USD}).to_return( - status: 200, - body: '{"rates": {"EUR": 0.85}}', - headers: { - "Content-Type" => "application/json", - }, - ) - modal.run_test - - expect(modal).to have_content("exchange_rate") - expect(modal).to have_content("0.85") - - modal.close - end - - it "allows admin to create a new AI tool from preset" do - visit "/admin/plugins/discourse-ai/ai-tools" - expect(page).to have_content("Tools") - - find(".ai-tool-list-editor__new-button").click - - tool_presets = PageObjects::Components::DMenu.new(find(".ai-tool-list-editor__new-button")) - tool_presets.option(".btn[data-option='exchange_rate']").click - - required_toggle_css = "#control-parameters-0-required .form-kit__control-checkbox" - enum_toggle_css = "#control-parameters-0-isEnum .form-kit__control-checkbox" - - expect(page.find(required_toggle_css).checked?).to eq(true) - expect(page.find(enum_toggle_css).checked?).to eq(false) - - # not allowed to test yet - expect(page).not_to have_button(".ai-tool-editor__test-button") - - expect(page).not_to have_button(".ai-tool-editor__delete") - find(".ai-tool-editor__save").click - - expect(page).to have_content("Tool saved") - - last_tool = AiTool.order("id desc").limit(1).first - visit "/admin/plugins/discourse-ai/ai-tools/#{last_tool.id}/edit" - - ensure_can_run_test - - expect(page.first(required_toggle_css).checked?).to eq(true) - expect(page.first(enum_toggle_css).checked?).to eq(false) - - visit "/admin/plugins/discourse-ai/ai-personas/new" - - tool_id = AiTool.order("id desc").limit(1).pluck(:id).first - tool_selector = PageObjects::Components::SelectKit.new("#control-tools .select-kit") - tool_selector.expand - - tool_selector.select_row_by_value("custom-#{tool_id}") - expect(tool_selector).to have_selected_value("custom-#{tool_id}") - end -end diff --git a/spec/system/ai_helper/ai_composer_helper_spec.rb b/spec/system/ai_helper/ai_composer_helper_spec.rb deleted file mode 100644 index e9ecd7a7..00000000 --- a/spec/system/ai_helper/ai_composer_helper_spec.rb +++ /dev/null @@ -1,432 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Composer helper", type: :system, js: true do - fab!(:user) { Fabricate(:admin, refresh_auto_groups: true) } - fab!(:non_member_group) { Fabricate(:group) } - fab!(:embedding_definition) - - before do - enable_current_plugin - Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) - assign_fake_provider_to(:ai_helper_model) - SiteSetting.ai_helper_enabled = true - Jobs.run_immediately! - sign_in(user) - end - - let(:input) { "The rain in spain stays mainly in the Plane." } - let(:composer) { PageObjects::Components::Composer.new } - let(:ai_helper_menu) { PageObjects::Components::AiComposerHelperMenu.new } - let(:diff_modal) { PageObjects::Modals::DiffModal.new } - let(:ai_suggestion_dropdown) { PageObjects::Components::AiSuggestionDropdown.new } - let(:toasts) { PageObjects::Components::Toasts.new } - let(:topic_page) { PageObjects::Pages::Topic.new } - - fab!(:category) - fab!(:category_2) { Fabricate(:category) } - fab!(:video) { Fabricate(:tag) } - fab!(:music) { Fabricate(:tag) } - fab!(:cloud) { Fabricate(:tag) } - fab!(:feedback) { Fabricate(:tag) } - fab!(:review) { Fabricate(:tag) } - fab!(:topic) { Fabricate(:topic, category: category, tags: [video, music]) } - fab!(:post) do - Fabricate( - :post, - topic: topic, - raw: - "I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.", - ) - end - - def trigger_composer_helper(content) - visit("/latest") - page.find("#create-topic").click - composer.fill_content(content) - composer.click_toolbar_button("ai-helper-trigger") - end - - context "when triggering composer AI helper" do - it "shows the context menu when clicking the AI button in the composer toolbar" do - trigger_composer_helper(input) - expect(ai_helper_menu).to have_context_menu - end - - it "shows a toast error when clicking the AI button without content" do - trigger_composer_helper("") - expect(ai_helper_menu).to have_no_context_menu - expect(toasts).to have_error(I18n.t("js.discourse_ai.ai_helper.no_content_error")) - end - - it "shows prompt options in menu when AI button is clicked" do - trigger_composer_helper(input) - expect(ai_helper_menu).to be_showing_options - end - - context "when using custom prompt" do - let(:mode) { DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT } - - let(:custom_prompt_input) { "Translate to French" } - let(:custom_prompt_response) { "La pluie en Espagne reste principalement dans l'avion." } - - it "shows custom prompt option" do - trigger_composer_helper(input) - expect(ai_helper_menu).to have_custom_prompt - end - - it "enables the custom prompt button when input is filled" do - trigger_composer_helper(input) - expect(ai_helper_menu).to have_custom_prompt_button_disabled - ai_helper_menu.fill_custom_prompt(custom_prompt_input) - expect(ai_helper_menu).to have_custom_prompt_button_enabled - end - - xit "replaces the composed message with AI generated content" do - # TODO: @keegan - this is a flake - # Failure/Error: super - - # Playwright::TimeoutError: - # Timeout 11000ms exceeded. - # Call log: - # - attempting click action - # - 2 × waiting for element to be visible, enabled and stable - # - - element is not enabled - # - - retrying click action - # - - waiting 20ms - # - 2 × waiting for element to be visible, enabled and stable - # - - element is not enabled - # - - retrying click action - # - - waiting 100ms - # - 21 × waiting for element to be visible, enabled and stable - # - - element is not enabled - # - - retrying click action - # - - waiting 500ms - - trigger_composer_helper(input) - ai_helper_menu.fill_custom_prompt(custom_prompt_input) - - DiscourseAi::Completions::Llm.with_prepared_responses([custom_prompt_response]) do - ai_helper_menu.click_custom_prompt_button - diff_modal.confirm_changes - wait_for { composer.composer_input.value == custom_prompt_response } - expect(composer.composer_input.value).to eq(custom_prompt_response) - end - end - end - - context "when not a member of custom prompt group" do - let(:mode) { DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT } - before { SiteSetting.ai_helper_custom_prompts_allowed_groups = non_member_group.id.to_s } - - it "does not show custom prompt option" do - trigger_composer_helper(input) - expect(ai_helper_menu).to have_no_custom_prompt - end - end - - context "when using translation mode" do - let(:mode) { DiscourseAi::AiHelper::Assistant::TRANSLATE } - - let(:spanish_input) { "La lluvia en España se queda principalmente en el avión." } - - it "replaces the composed message with AI generated content" do - trigger_composer_helper(spanish_input) - - DiscourseAi::Completions::Llm.with_prepared_responses([input]) do - ai_helper_menu.select_helper_model(mode) - diff_modal.confirm_changes - wait_for { composer.composer_input.value == input } - expect(composer.composer_input.value).to eq(input) - end - end - - it "reverts results when Ctrl/Cmd + Z is pressed on the keyboard" do - trigger_composer_helper(spanish_input) - - DiscourseAi::Completions::Llm.with_prepared_responses([input]) do - ai_helper_menu.select_helper_model(mode) - diff_modal.confirm_changes - wait_for { composer.composer_input.value == input } - ai_helper_menu.press_undo_keys - expect(composer.composer_input.value).to eq(spanish_input) - end - end - - it "shows the changes in a modal" do - trigger_composer_helper(spanish_input) - - DiscourseAi::Completions::Llm.with_prepared_responses([input]) do - ai_helper_menu.select_helper_model(mode) - - expect(diff_modal).to be_visible - expect(diff_modal.old_value).to eq(spanish_input.gsub(/[[:space:]]+/, " ").strip) - expect(diff_modal.new_value).to eq( - input.gsub(/[[:space:]]+/, " ").gsub(/[‘’]/, "'").gsub(/[“”]/, '"').strip, - ) - diff_modal.confirm_changes - expect(ai_helper_menu).to have_no_context_menu - end - end - - it "does not apply the changes when discard button is pressed in the modal" do - trigger_composer_helper(spanish_input) - DiscourseAi::Completions::Llm.with_prepared_responses([input]) do - ai_helper_menu.select_helper_model(mode) - expect(diff_modal).to be_visible - diff_modal.discard_changes - expect(ai_helper_menu).to have_no_context_menu - expect(composer.composer_input.value).to eq(spanish_input) - end - end - end - - context "when using the proofreading mode" do - let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } - - let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." } - - it "replaces the composed message with AI generated content" do - trigger_composer_helper(input) - - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do - ai_helper_menu.select_helper_model(mode) - diff_modal.confirm_changes - wait_for { composer.composer_input.value == proofread_text } - expect(composer.composer_input.value).to eq(proofread_text) - end - end - end - end - - context "when suggesting titles with AI title suggester" do - let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } - - let(:titles) do - { - output: [ - "Rainy Spain", - "Plane-Bound Delights", - "Mysterious Spain", - "Plane-Rain Chronicles", - "Unveiling Spain", - ], - } - end - - it "opens a menu with title suggestions" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do - ai_suggestion_dropdown.click_suggest_titles_button - - wait_for { ai_suggestion_dropdown.has_dropdown? } - - expect(ai_suggestion_dropdown).to have_dropdown - end - end - - it "replaces the topic title with the selected title" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do - ai_suggestion_dropdown.click_suggest_titles_button - - wait_for { ai_suggestion_dropdown.has_dropdown? } - - ai_suggestion_dropdown.select_suggestion_by_value(1) - expected_title = "Plane-Bound Delights" - - expect(find("#reply-title").value).to eq(expected_title) - end - end - - it "closes the menu when clicking outside" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - - DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do - ai_suggestion_dropdown.click_suggest_titles_button - - wait_for { ai_suggestion_dropdown.has_dropdown? } - - find(".d-editor-preview").click - - expect(ai_suggestion_dropdown).to have_no_dropdown - end - end - - it "only shows trigger button if there is sufficient content in the composer" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content("abc") - - expect(ai_suggestion_dropdown).to have_no_suggestion_button - - composer.fill_content(input) - expect(ai_suggestion_dropdown).to have_suggestion_button - end - end - - context "when suggesting the category with AI category suggester" do - before do - SiteSetting.ai_embeddings_selected_model = embedding_definition.id - SiteSetting.ai_embeddings_enabled = true - end - - it "updates the category with the suggested category" do - response = - Category - .take(3) - .map do |category| - { - id: category.id, - name: category.name, - slug: category.slug, - color: category.color, - score: rand(0.0...45.0), - topicCount: rand(1..3), - } - end - .sort_by { |h| h[:score] } - DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:categories).returns(response) - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - ai_suggestion_dropdown.click_suggest_category_button - wait_for { ai_suggestion_dropdown.has_dropdown? } - suggestion = category_2.name - ai_suggestion_dropdown.select_suggestion_by_name(suggestion) - category_selector = page.find(".category-chooser summary") - expect(category_selector["data-name"]).to eq(suggestion) - end - end - - context "when suggesting the tags with AI tag suggester" do - before do - SiteSetting.ai_embeddings_selected_model = embedding_definition.id - SiteSetting.ai_embeddings_enabled = true - end - - it "updates the tag with the suggested tag" do - response = - Tag - .take(7) - .pluck(:name) - .map { |s| { name: s, score: rand(0.0...45.0) } } - .sort { |h| h[:score] } - DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:tags).returns(response) - - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - - ai_suggestion_dropdown.click_suggest_tags_button - - wait_for { ai_suggestion_dropdown.has_dropdown? } - - suggestion = ai_suggestion_dropdown.suggestion_name(0) - ai_suggestion_dropdown.select_suggestion_by_value(0) - tag_selector = page.find(".mini-tag-chooser summary") - - expect(tag_selector["data-name"]).to eq(suggestion) - end - - it "does not suggest tags that already exist" do - response = - Tag - .take(7) - .pluck(:name) - .map { |s| { name: s, score: rand(0.0...45.0) } } - .sort { |h| h[:score] } - DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:tags).returns(response) - - topic_page.visit_topic(topic) - page.find(".edit-topic", visible: false).click - page.find(".ai-tag-suggester-trigger").click - tag1_css = ".ai-tag-suggester-content btn[data-name='#{video.name}']" - tag2_css = ".ai-tag-suggester-content btn[data-name='#{music.name}']" - - expect(page).to have_no_css(tag1_css) - expect(page).to have_no_css(tag2_css) - end - end - - context "when AI helper is disabled" do - let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } - before { SiteSetting.ai_helper_enabled = false } - - it "does not show the AI helper button in the composer toolbar" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger") - end - - it "does not trigger AI suggestion buttons" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - expect(ai_suggestion_dropdown).to have_no_suggestion_button - end - end - - context "when user is not a member of AI helper allowed group" do - let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } - before { SiteSetting.composer_ai_helper_allowed_groups = non_member_group.id.to_s } - - it "does not show the AI helper button in the composer toolbar" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger") - end - - it "does not trigger AI suggestion buttons" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - expect(ai_suggestion_dropdown).to have_no_suggestion_button - end - end - - context "when suggestion features are disabled" do - let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } - before { SiteSetting.ai_helper_enabled_features = "context_menu" } - - it "does not show suggestion buttons in the composer" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - expect(ai_suggestion_dropdown).to have_no_suggestion_button - end - end - - context "when composer helper feature is disabled" do - before { SiteSetting.ai_helper_enabled_features = "suggestions" } - - it "does not show button in the composer toolbar" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger") - end - end - - context "when triggering composer AI helper", mobile: true do - it "should close the composer helper before showing the diff modal" do - visit("/latest") - page.find("#create-topic").click - composer.fill_content(input) - composer.click_toolbar_button("ai-helper-trigger") - - DiscourseAi::Completions::Llm.with_prepared_responses([input]) do - ai_helper_menu.select_helper_model(DiscourseAi::AiHelper::Assistant::TRANSLATE) - expect(ai_helper_menu).to have_no_context_menu - expect(diff_modal).to be_visible - end - end - end -end diff --git a/spec/system/ai_helper/ai_image_caption_spec.rb b/spec/system/ai_helper/ai_image_caption_spec.rb deleted file mode 100644 index 0d31ab89..00000000 --- a/spec/system/ai_helper/ai_image_caption_spec.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI image caption", type: :system, js: true do - fab!(:user) { Fabricate(:admin, refresh_auto_groups: true) } - fab!(:non_member_group) { Fabricate(:group) } - let(:user_preferences_ai_page) { PageObjects::Pages::UserPreferencesAi.new } - let(:topic_page) { PageObjects::Pages::Topic.new } - fab!(:topic) - fab!(:post) { Fabricate(:post, topic: topic) } - let(:composer) { PageObjects::Components::Composer.new } - let(:popup) { PageObjects::Components::AiCaptionPopup.new } - let(:dialog) { PageObjects::Components::Dialog.new } - let(:file_path) { plugin_file_from_fixtures("100x100.jpg").path } - let(:captioned_image_path) do - plugin_file_from_fixtures("An image of discobot in action.png").path - end - let(:caption) do - "The image shows a stylized speech bubble icon with a multicolored border on a black background." - end - let(:caption_with_attrs) do - "#{caption} (#{I18n.t("discourse_ai.ai_helper.image_caption.attribution")})" - end - - before do - enable_current_plugin - Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) - assign_fake_provider_to(:ai_helper_model) - assign_fake_provider_to(:ai_helper_image_caption_model) - SiteSetting.ai_helper_enabled = true - SiteSetting.ai_helper_enabled_features = "image_caption" - sign_in(user) - end - - shared_examples "shows no image caption button" do - it "should not show an image caption button" do - visit("/latest") - page.find("#create-topic").click - attach_file([file_path]) { composer.click_toolbar_button("upload") } - wait_for { composer.has_no_in_progress_uploads? } - expect(popup).to have_no_generate_caption_button - end - end - - context "when not a member of ai helper group" do - before { SiteSetting.composer_ai_helper_allowed_groups = non_member_group.id.to_s } - include_examples "shows no image caption button" - end - - context "when image caption feature is disabled" do - before { SiteSetting.ai_helper_enabled_features = "" } - include_examples "shows no image caption button" - end - - context "when triggering caption with AI on desktop" do - it "should show an image caption in an input field" do - DiscourseAi::Completions::Llm.with_prepared_responses([caption]) do - visit("/latest") - page.find("#create-topic").click - attach_file([file_path]) { composer.click_toolbar_button("upload") } - popup.click_generate_caption - expect(popup.has_caption_popup_value?(caption_with_attrs)).to eq(true) - popup.save_caption - wait_for { page.find(".image-wrapper img")["alt"] == caption_with_attrs } - expect(page.find(".image-wrapper img")["alt"]).to eq(caption_with_attrs) - end - end - - it "should allow you to cancel a caption request" do - DiscourseAi::Completions::Llm.with_prepared_responses([caption]) do - visit("/latest") - page.find("#create-topic").click - attach_file([file_path]) { composer.click_toolbar_button("upload") } - popup.click_generate_caption - popup.cancel_caption - expect(popup).to have_no_disabled_generate_button - end - end - end - - context "when triggering caption with AI on mobile", mobile: true do - it "should show update the image alt text with the caption" do - DiscourseAi::Completions::Llm.with_prepared_responses([caption]) do - visit("/latest") - page.find("#create-topic").click - attach_file([file_path]) { page.find(".mobile-file-upload").click } - page.find(".mobile-preview").click - popup.click_generate_caption - wait_for { page.find(".image-wrapper img")["alt"] == caption_with_attrs } - expect(page.find(".image-wrapper img")["alt"]).to eq(caption_with_attrs) - end - end - end - - describe "automatic image captioning" do - context "when the user preference is disabled" do - before { user.user_option.update!(auto_image_caption: false) } - - it "should show a prompt when submitting a post with captionable images uploaded" do - visit("/latest") - page.find("#create-topic").click - attach_file([file_path]) { composer.click_toolbar_button("upload") } - wait_for { composer.has_no_in_progress_uploads? } - composer.fill_title("I love using Discourse! It is my favorite forum software") - composer.create - expect(dialog).to be_open - end - - it "should not show a prompt when submitting a post with no captionable images uploaded" do - visit("/latest") - page.find("#create-topic").click - attach_file([captioned_image_path]) { composer.click_toolbar_button("upload") } - wait_for { composer.has_no_in_progress_uploads? } - composer.fill_title("I love using Discourse! It is my favorite forum software") - composer.create - expect(dialog).to be_closed - end - - it "should auto caption the existing images and update the preference when dialog is accepted" do - DiscourseAi::Completions::Llm.with_prepared_responses([caption]) do - visit("/latest") - page.find("#create-topic").click - attach_file([file_path]) { composer.click_toolbar_button("upload") } - wait_for { composer.has_no_in_progress_uploads? } - composer.fill_title("I love using Discourse! It is my favorite forum software") - composer.create - dialog.click_yes - wait_for(timeout: 100) { page.find("#post_1 .cooked img")["alt"] == caption_with_attrs } - expect(page.find("#post_1 .cooked img")["alt"]).to eq(caption_with_attrs) - end - end - end - - context "when a post has no uploads" do - before { user.user_option.update!(auto_image_caption: true) } - - it "should create the topic without triggering auto caption" do - title = "I love using Discourse! It is my favorite forum software" - visit("/latest") - page.find("#create-topic").click - composer.fill_title(title) - composer.fill_content( - "Culpa labore velit cupidatat commodo magna esse et minim consequat veniam dolore eiusmod. Labore eu elit in nulla ipsum elit consectetur sunt consectetur enim. Cillum ex ex velit eiusmod labore ullamco ut ad. Dolore dolor commodo nisi fugiat esse quis anim officia quis. Pariatur cillum pariatur irure cupidatat nostrud ullamco labore id aliqua ut nostrud. Eu adipisicing ut laboris.", - ) - composer.create - expect(dialog).to be_closed - expect(topic_page).to have_topic_title(title) - end - - it "should create a reply to a post without triggering auto caption" do - visit("/t/-/#{topic.id}") - topic_page.click_footer_reply - composer.fill_content( - "Culpa labore velit cupidatat commodo magna esse et minim consequat veniam dolore eiusmod. Labore eu elit in nulla ipsum elit consectetur sunt consectetur enim. Cillum ex ex velit eiusmod labore ullamco ut ad. Dolore dolor commodo nisi fugiat esse quis anim officia quis. Pariatur cillum pariatur irure cupidatat nostrud ullamco labore id aliqua ut nostrud. Eu adipisicing ut laboris.", - ) - composer.create - expect(dialog).to be_closed - expect(topic_page).to have_post_number(post.post_number) - end - end - end -end diff --git a/spec/system/ai_helper/ai_post_helper_spec.rb b/spec/system/ai_helper/ai_post_helper_spec.rb deleted file mode 100644 index a836671e..00000000 --- a/spec/system/ai_helper/ai_post_helper_spec.rb +++ /dev/null @@ -1,188 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Post helper", type: :system, js: true do - fab!(:user) { Fabricate(:admin) } - fab!(:non_member_group) { Fabricate(:group) } - fab!(:topic) - fab!(:post) do - Fabricate( - :post, - topic: topic, - raw: - "I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.", - ) - end - fab!(:post_2) do - Fabricate(:post, topic: topic, raw: "La lluvia en España se queda principalmente en el avión.") - end - fab!(:post_3) do - Fabricate( - :post, - topic: topic, - raw: "The Toyota Supra delivrs 382 horsepwr makin it a very farst car.", - ) - end - let(:topic_page) { PageObjects::Pages::Topic.new } - let(:post_ai_helper) { PageObjects::Components::AiPostHelperMenu.new } - let(:fast_editor) { PageObjects::Components::FastEditor.new } - - before do - enable_current_plugin - Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) - assign_fake_provider_to(:ai_helper_model) - SiteSetting.ai_helper_enabled = true - Jobs.run_immediately! - sign_in(user) - end - - def select_post_text(selected_post) - topic_page.visit_topic(topic) - page.execute_script( - "var element = document.querySelector('#{topic_page.post_by_number_selector(selected_post.post_number)} .cooked p'); " + - "var range = document.createRange(); " + "range.selectNodeContents(element); " + - "var selection = window.getSelection(); " + "selection.removeAllRanges(); " + - "selection.addRange(range);" + "const event = new PointerEvent('pointerup');" + - "document.dispatchEvent(event);", - ) - end - - context "when triggering AI helper in post" do - it "shows the Ask AI button in the post selection toolbar" do - select_post_text(post) - expect(post_ai_helper).to have_post_selection_toolbar - expect(post_ai_helper).to have_post_ai_helper - end - - it "shows AI helper options after clicking the AI button" do - select_post_text(post) - post_ai_helper.click_ai_button - expect(post_ai_helper).to have_no_post_selection_primary_buttons - expect(post_ai_helper).to have_post_ai_helper_options - end - - it "should not have the mobile post AI helper" do - select_post_text(post) - post_ai_helper.click_ai_button - expect(post_ai_helper).to have_no_mobile_post_ai_helper - end - - it "highlights the selected text after clicking the AI button and removes after closing" do - select_post_text(post) - post_ai_helper.click_ai_button - expect(post_ai_helper).to have_highlighted_text - find("article[data-post-id='#{post.id}']").click - expect(post_ai_helper).to have_no_highlighted_text - end - - it "allows post control buttons to still be functional after clicking the AI button" do - select_post_text(post) - post_ai_helper.click_ai_button - topic_page.click_like_reaction_for(post) - wait_for { post.reload.like_count == 1 } - expect(post.like_count).to eq(1) - end - - context "when using proofread mode" do - let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } - let(:proofread_response) do - "The Toyota Supra delivers 382 horsepower making it a very fast car." - end - - it "pre-fills fast edit with proofread text" do - select_post_text(post_3) - post_ai_helper.click_ai_button - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do - post_ai_helper.select_helper_model(mode) - expect(page.find("#fast-edit-input")).to have_content(proofread_response) - end - end - end - - context "when using explain mode" do - let(:mode) { DiscourseAi::AiHelper::Assistant::EXPLAIN } - let(:explain_response) { "This is about pie." } - - it "shows the explanation in the AI helper" do - select_post_text(post) - post_ai_helper.click_ai_button - - DiscourseAi::Completions::Llm.with_prepared_responses([explain_response]) do - post_ai_helper.select_helper_model(mode) - expect(post_ai_helper).to have_suggestion_value(explain_response) - end - end - - context "with footnotes enabled" do - before do - SiteSetting.enable_markdown_footnotes = true - SiteSetting.display_footnotes_inline = true - end - - it "allows adding the explanation as a footnote to the post" do - select_post_text(post) - post_ai_helper.click_ai_button - - DiscourseAi::Completions::Llm.with_prepared_responses([explain_response]) do - post_ai_helper.select_helper_model(mode) - - expect(post_ai_helper).to have_suggestion_value(explain_response) - - post_ai_helper.click_add_footnote - - expect(post_ai_helper).to have_no_post_ai_helper - - expect(post.reload.raw).to include( - "^[#{explain_response} (#{I18n.t("js.discourse_ai.ai_helper.post_options_menu.footnote_credits")})]", - ) - end - end - end - end - end - - context "when AI helper is disabled" do - before { SiteSetting.ai_helper_enabled = false } - - it "does not show the Ask AI button in the post selection toolbar" do - select_post_text(post) - expect(post_ai_helper).to have_post_selection_toolbar - expect(post_ai_helper).to have_no_post_ai_helper - end - end - - context "when user is not a member of the post AI helper allowed group" do - before do - SiteSetting.ai_helper_enabled = true - SiteSetting.post_ai_helper_allowed_groups = non_member_group.id.to_s - end - - it "does not show the Ask AI button in the post selection toolbar" do - select_post_text(post) - expect(post_ai_helper).to have_post_selection_toolbar - expect(post_ai_helper).to have_no_post_ai_helper - end - end - - context "when triggering AI proofread through edit button" do - let(:proofread_response) do - "The Toyota Supra delivers 382 horsepower making it a very fast car." - end - - it "pre-fills fast edit with proofread text" do - select_post_text(post_3) - find(".quote-edit-label").click - DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do - find(".btn-ai-suggest-edit", visible: :all).click - expect(page.find("#fast-edit-input")).to have_content(proofread_response) - end - end - end - - context "when triggering post AI helper on mobile", mobile: true do - it "should use the bottom modal instead of the popup menu" do - select_post_text(post) - post_ai_helper.click_ai_button - expect(post_ai_helper).to have_mobile_post_ai_helper - end - end -end diff --git a/spec/system/ai_helper/ai_proofreading_spec.rb b/spec/system/ai_helper/ai_proofreading_spec.rb deleted file mode 100644 index c9ed4619..00000000 --- a/spec/system/ai_helper/ai_proofreading_spec.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -include SystemHelpers - -RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do - fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) } - - before do - enable_current_plugin - assign_fake_provider_to(:ai_helper_model) - SiteSetting.ai_helper_enabled = true - - # This needs to be done because the streaming suggestions for composer - # happen in a background job, which sends the MessageBus event to the client. - Jobs.run_immediately! - sign_in(admin) - end - - let(:composer) { PageObjects::Components::Composer.new } - let(:rich) { composer.rich_editor } - let(:toasts) { PageObjects::Components::Toasts.new } - let(:diff_modal) { PageObjects::Modals::DiffModal.new } - let(:keyboard_shortcut) { [PLATFORM_KEY_MODIFIER, :alt, "p"] } - - context "when triggering via keyboard shortcut" do - it "proofreads selected text" do - visit "/new-topic" - composer.fill_content("hello worldd !") - - composer.select_range(6, 12) - - DiscourseAi::Completions::Llm.with_prepared_responses(["world"]) do - composer.composer_input.send_keys(keyboard_shortcut) - expect(diff_modal).to have_diff("worldd", "world") - diff_modal.confirm_changes - expect(composer.composer_input.value).to eq("hello world !") - end - end - - it "proofreads all text when nothing is selected" do - visit "/new-topic" - composer.fill_content("hello worrld") - - # Simulate AI response - DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do - composer.composer_input.send_keys(keyboard_shortcut) - expect(diff_modal).to have_diff("worrld", "world") - diff_modal.confirm_changes - expect(composer.composer_input.value).to eq("hello world") - end - end - - it "does not trigger proofread modal if composer is empty" do - visit "/new-topic" - - # Simulate AI response - DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do - composer.composer_input.send_keys(keyboard_shortcut) - expect(toasts).to have_error(I18n.t("js.discourse_ai.ai_helper.no_content_error")) - end - end - - context "when using rich text editor" do - before { SiteSetting.rich_editor = true } - - it "proofreads selected text and replaces it" do - visit "/new-topic" - expect(composer).to be_opened - composer.toggle_rich_editor - composer.type_content("hello worldd !") - - # NOTE: The rich text editor cannot use select_range on the page object since it is - # a contenteditable element. It would be hard to make this generic enough to put in - # the page object, maybe at some point in the future we can refactor this. - execute_script(<<~JS, text) - const composer = document.querySelector("#reply-control .d-editor-input"); - const startNode = composer.firstChild.firstChild; - composer.focus(); - const range = document.createRange(); - range.setStart(startNode, 6); - range.setEnd(startNode, 12); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - JS - - DiscourseAi::Completions::Llm.with_prepared_responses(["world"]) do - composer.composer_input.send_keys(keyboard_shortcut) - expect(diff_modal).to have_diff("worldd", "world") - diff_modal.confirm_changes - expect(rich).to have_css("p", text: "hello world !") - end - end - end - end -end diff --git a/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb b/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb deleted file mode 100644 index 8a6b7803..00000000 --- a/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb +++ /dev/null @@ -1,144 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Post helper", type: :system, js: true do - fab!(:user) { Fabricate(:admin) } - fab!(:non_member_group) { Fabricate(:group) } - fab!(:topic) - fab!(:category) - fab!(:category_2) { Fabricate(:category) } - fab!(:post) do - Fabricate( - :post, - topic: topic, - raw: - "I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.", - ) - end - fab!(:post_2) do - Fabricate( - :post, - topic: topic, - raw: "I prefer to eat croissants. They are my personal favorite dessert!", - ) - end - fab!(:post_3) do - Fabricate( - :post, - topic: topic, - raw: "I disagree with both of you, I think cake is the best dessert.", - ) - end - let(:topic_page) { PageObjects::Pages::Topic.new } - let(:suggestion_menu) { PageObjects::Components::AiSplitTopicSuggester.new } - fab!(:video) { Fabricate(:tag) } - fab!(:music) { Fabricate(:tag) } - fab!(:cloud) { Fabricate(:tag) } - fab!(:feedback) { Fabricate(:tag) } - fab!(:review) { Fabricate(:tag) } - fab!(:embedding_definition) - - before do - enable_current_plugin - Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) - assign_fake_provider_to(:ai_helper_model) - SiteSetting.ai_helper_enabled = true - sign_in(user) - end - - def open_move_topic_modal - topic_page.visit_topic(topic) - find(".topic-timeline .toggle-admin-menu").click - find(".topic-admin-multi-select .btn").click - find("#post_2 .select-posts .select-below").click - find(".move-to-topic").click - end - - describe "moving posts to a new topic" do - context "when suggesting titles with AI title suggester" do - let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } - let(:titles) do - { - output: [ - "Pie: A delicious dessert", - "Cake is the best!", - "Croissants are delightful", - "Some great desserts", - "What is the best dessert?", - ], - } - end - - it "opens a menu with title suggestions" do - open_move_topic_modal - DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do - suggestion_menu.click_suggest_titles_button - wait_for { suggestion_menu.has_dropdown? } - expect(suggestion_menu).to have_dropdown - end - end - - it "replaces the title input with the selected title" do - open_move_topic_modal - DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do - suggestion_menu.click_suggest_titles_button - wait_for { suggestion_menu.has_dropdown? } - suggestion_menu.select_suggestion_by_value(1) - expected_title = "Cake is the best!" - expect(find("#split-topic-name").value).to eq(expected_title) - end - end - end - - context "when suggesting categories with AI category suggester" do - before do - SiteSetting.ai_embeddings_selected_model = embedding_definition.id - SiteSetting.ai_embeddings_enabled = true - end - - it "updates the category with the suggested category" do - response = - Category - .take(3) - .pluck(:id, :name) - .map { |s| { id: s[0], name: s[1], score: rand(0.0...45.0) } } - .sort { |h| h[:score] } - DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:categories).returns(response) - - open_move_topic_modal - suggestion_menu.click_suggest_category_button - wait_for { suggestion_menu.has_dropdown? } - suggestion = category.name - suggestion_menu.select_suggestion_by_name(suggestion) - category_selector = page.find(".category-chooser summary") - - expect(category_selector["data-name"]).to eq(suggestion) - end - end - - context "when suggesting tags with AI tag suggester" do - before do - SiteSetting.ai_embeddings_selected_model = embedding_definition.id - SiteSetting.ai_embeddings_enabled = true - end - - it "update the tag with the suggested tag" do - response = - Tag - .take(5) - .pluck(:name) - .map { |s| { name: s, score: rand(0.0...45.0) } } - .sort { |h| h[:score] } - DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:tags).returns(response) - - open_move_topic_modal - suggestion_menu.click_suggest_tags_button - wait_for { suggestion_menu.has_dropdown? } - - suggestion = suggestion_menu.suggestion_name(0) - suggestion_menu.select_suggestion_by_value(0) - tag_selector = page.find(".tag-chooser summary") - expect(tag_selector["data-name"]).to eq(suggestion) - end - end - end -end diff --git a/spec/system/ai_moderation/ai_spam_spec.rb b/spec/system/ai_moderation/ai_spam_spec.rb deleted file mode 100644 index baf320d6..00000000 --- a/spec/system/ai_moderation/ai_spam_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Spam Configuration", type: :system, js: true do - fab!(:admin) - let(:llm_model) { Fabricate(:llm_model) } - - before do - enable_current_plugin - sign_in(admin) - end - - it "can properly configure spam settings" do - visit "/admin/plugins/discourse-ai/ai-spam" - - expect(page).to have_css(".ai-spam__llm-placeholder") - - toggle = PageObjects::Components::DToggleSwitch.new(".ai-spam__toggle") - - toggle.toggle - dialog = PageObjects::Components::Dialog.new - expect(dialog).to have_content(I18n.t("discourse_ai.llm.configuration.must_select_model")) - dialog.click_ok - - expect(toggle.unchecked?).to eq(true) - - llm_model - visit "/admin/plugins/discourse-ai/ai-spam" - - toggle = PageObjects::Components::DToggleSwitch.new(".ai-spam__toggle") - toggle.toggle - - try_until_success { expect(AiModerationSetting.spam&.llm_model_id).to eq(llm_model.id) } - - find(".ai-spam__instructions-input").fill_in(with: "Test spam detection instructions") - find(".ai-spam__instructions-save").click - - toasts = PageObjects::Components::Toasts.new - expect(toasts).to have_content(I18n.t("js.discourse_ai.spam.settings_saved")) - - expect(AiModerationSetting.spam.custom_instructions).to eq("Test spam detection instructions") - - visit "/admin/plugins/discourse-ai/ai-llms" - - expect(find(".ai-llm-list-editor__usages")).to have_content( - I18n.t("js.discourse_ai.llms.usage.ai_spam"), - ) - end -end diff --git a/spec/system/ai_user_preferences_spec.rb b/spec/system/ai_user_preferences_spec.rb deleted file mode 100644 index 105b624b..00000000 --- a/spec/system/ai_user_preferences_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "User AI preferences", type: :system, js: true do - fab!(:user) { Fabricate(:admin, refresh_auto_groups: true) } - fab!(:llm_model) - let(:user_preferences_ai_page) { PageObjects::Pages::UserPreferencesAi.new } - fab!(:discovery_persona) do - Fabricate(:ai_persona, allowed_group_ids: [Group::AUTO_GROUPS[:admins]]) - end - - before do - enable_current_plugin - SiteSetting.ai_bot_discover_persona = discovery_persona.id - Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) - assign_fake_provider_to(:ai_helper_model) - assign_fake_provider_to(:ai_helper_image_caption_model) - sign_in(user) - end - - describe "automatic image caption setting" do - context "when ai helper is disabled" do - before { SiteSetting.ai_helper_enabled = false } - - it "should not have the setting present in the user preferences page" do - user_preferences_ai_page.visit(user) - expect(user_preferences_ai_page).to have_no_ai_preference("pref-auto-image-caption") - end - end - - context "when toggling the setting from the user preferences page" do - before do - SiteSetting.ai_helper_enabled = true - SiteSetting.ai_helper_enabled_features = "image_caption" - user.user_option.update!(auto_image_caption: false) - end - - it "should update the preference to enabled" do - user_preferences_ai_page.visit(user) - user_preferences_ai_page.toggle_setting("pref-auto-image-caption") - user_preferences_ai_page.save_changes - wait_for(timeout: 5) { user.reload.user_option.auto_image_caption } - expect(user.reload.user_option.auto_image_caption).to eq(true) - end - end - end - - describe "search discoveries setting" do - context "when discoveries are enabled" do - before { SiteSetting.ai_bot_enabled = true } - it "should have the setting present in the user preferences page" do - user_preferences_ai_page.visit(user) - expect(user_preferences_ai_page).to have_ai_preference("pref-ai-search-discoveries") - end - end - - context "when discoveries are disabled" do - SiteSetting.ai_bot_enabled = false - SiteSetting.ai_bot_discover_persona = nil - - it "should not have the setting present in the user preferences page" do - user_preferences_ai_page.visit(user) - expect(user_preferences_ai_page).to have_no_ai_preference("pref-ai-search-discoveries") - end - end - end - - describe "when no settings are available" do - before do - SiteSetting.ai_helper_enabled = false - SiteSetting.ai_bot_enabled = false - end - - it "should not have any AI preferences and should show a message" do - user_preferences_ai_page.visit(user) - expect(user_preferences_ai_page).to have_no_ai_preference("pref-auto-image-caption") - expect(user_preferences_ai_page).to have_no_ai_preference("pref-ai-search-discoveries") - expect(page).to have_content(I18n.t("js.discourse_ai.user_preferences.empty")) - end - end -end diff --git a/spec/system/core_features_spec.rb b/spec/system/core_features_spec.rb deleted file mode 100644 index db86db28..00000000 --- a/spec/system/core_features_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Core features", type: :system do - before { enable_current_plugin } - - it_behaves_like "having working core features" -end diff --git a/spec/system/embeddings/ai_embedding_definition_spec.rb b/spec/system/embeddings/ai_embedding_definition_spec.rb deleted file mode 100644 index f465ecff..00000000 --- a/spec/system/embeddings/ai_embedding_definition_spec.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Managing Embeddings configurations", type: :system, js: true do - fab!(:admin) - let(:page_header) { PageObjects::Components::DPageHeader.new } - let(:form) { PageObjects::Components::FormKit.new("form") } - - before do - enable_current_plugin - sign_in(admin) - end - - it "correctly sets defaults" do - preset = "text-embedding-3-small" - api_key = "abcd" - - visit "/admin/plugins/discourse-ai/ai-embeddings" - - find(".ai-embeddings-list-editor__new-button").click() - - find("[data-preset-id='text-embedding-3-small'] button").click() - - form.field("api_key").fill_in(api_key) - form.submit - - expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-embeddings") - - embedding_def = EmbeddingDefinition.order(:id).last - expect(embedding_def.api_key).to eq(api_key) - - preset = EmbeddingDefinition.presets.find { |p| p[:preset_id] == preset } - - expect(embedding_def.display_name).to eq(preset[:display_name]) - expect(embedding_def.url).to eq(preset[:url]) - expect(embedding_def.tokenizer_class).to eq(preset[:tokenizer_class]) - expect(embedding_def.dimensions).to eq(preset[:dimensions]) - expect(embedding_def.max_sequence_length).to eq(preset[:max_sequence_length]) - expect(embedding_def.pg_function).to eq(preset[:pg_function]) - expect(embedding_def.provider).to eq(preset[:provider]) - expect(embedding_def.provider_params.symbolize_keys).to eq(preset[:provider_params]) - end - - it "supports manual config" do - api_key = "abcd" - - visit "/admin/plugins/discourse-ai/ai-embeddings" - - find(".ai-embeddings-list-editor__new-button").click() - - find("[data-preset-id='manual'] button").click() - - form.field("display_name").fill_in("text-embedding-3-small") - form.field("provider").select(EmbeddingDefinition::OPEN_AI) - form.field("url").fill_in("https://api.openai.com/v1/embeddings") - form.field("api_key").fill_in(api_key) - form.field("tokenizer_class").select("DiscourseAi::Tokenizer::OpenAiCl100kTokenizer") - - embed_prefix = "On creation:" - search_prefix = "On search:" - form.field("embed_prompt").fill_in(embed_prefix) - form.field("search_prompt").fill_in(search_prefix) - form.field("dimensions").fill_in(1536) - form.field("max_sequence_length").fill_in(8191) - form.field("pg_function").select("<=>") - form.field("provider_params.model_name").fill_in("text-embedding-3-small") - - form.submit - - expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-embeddings") - - embedding_def = EmbeddingDefinition.order(:id).last - - expect(embedding_def.api_key).to eq(api_key) - - preset = EmbeddingDefinition.presets.find { |p| p[:preset_id] == "text-embedding-3-small" } - - expect(embedding_def.display_name).to eq(preset[:display_name]) - expect(embedding_def.url).to eq(preset[:url]) - expect(embedding_def.tokenizer_class).to eq(preset[:tokenizer_class]) - expect(embedding_def.dimensions).to eq(preset[:dimensions]) - expect(embedding_def.max_sequence_length).to eq(preset[:max_sequence_length]) - expect(embedding_def.pg_function).to eq(preset[:pg_function]) - expect(embedding_def.provider).to eq(preset[:provider]) - expect(embedding_def.embed_prompt).to eq(embed_prefix) - expect(embedding_def.search_prompt).to eq(search_prefix) - end -end diff --git a/spec/system/embeddings/semantic_search_spec.rb b/spec/system/embeddings/semantic_search_spec.rb deleted file mode 100644 index 15370897..00000000 --- a/spec/system/embeddings/semantic_search_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "AI Composer helper", type: :system, js: true do - let(:search_page) { PageObjects::Pages::Search.new } - let(:query) { "apple_pie" } - let(:hypothetical_post) { "This is an hypothetical post generated from the keyword apple_pie" } - - fab!(:user) { Fabricate(:admin) } - fab!(:topic) - fab!(:post) { Fabricate(:post, topic: topic, raw: "Apple pie is a delicious dessert to eat") } - - before do - enable_current_plugin - - prompt = DiscourseAi::Embeddings::HydeGenerators::OpenAi.new.prompt(query) - OpenAiCompletionsInferenceStubs.stub_response( - prompt, - hypothetical_post, - req_opts: { - max_tokens: 400, - }, - ) - - hyde_embedding = [0.049382, 0.9999] - EmbeddingsGenerationStubs.hugging_face_service(hypothetical_post, hyde_embedding) - - SearchIndexer.enable - SearchIndexer.index(topic, force: true) - SiteSetting.ai_embeddings_semantic_search_enabled = true - sign_in(user) - end - - after do - described_class.clear_cache_for(query) - SearchIndexer.disable - end - - describe "when performing a search in the full page search page" do - skip "TODO: Implement test after doing LLM abstraction" do - it "performs AI search in the background and hides results by default" do - visit("/search?expanded=true") - search_page.type_in_search("apple pie") - search_page.click_search_button - end - end - end -end diff --git a/spec/system/llms/ai_llm_spec.rb b/spec/system/llms/ai_llm_spec.rb deleted file mode 100644 index 57d36642..00000000 --- a/spec/system/llms/ai_llm_spec.rb +++ /dev/null @@ -1,201 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Managing LLM configurations", type: :system, js: true do - fab!(:admin) - - let(:page_header) { PageObjects::Components::DPageHeader.new } - let(:form) { PageObjects::Components::FormKit.new("form") } - - before do - enable_current_plugin - SiteSetting.ai_bot_enabled = true - sign_in(admin) - end - - it "correctly sets defaults" do - visit "/admin/plugins/discourse-ai/ai-llms" - - find("[data-llm-id='anthropic-claude-opus-4-0'] button").click() - form.field("api_key").fill_in("abcd") - form.field("enabled_chat_bot").toggle - form.submit - - expect(page).to have_current_path(%r{/admin/plugins/discourse-ai/ai-llms/\d+/edit}) - - llm = LlmModel.order(:id).last - - expect(llm.api_key).to eq("abcd") - - preset = DiscourseAi::Completions::Llm.presets.find { |p| p[:id] == "anthropic" } - model_preset = preset[:models].find { |m| m[:name] == "claude-opus-4-0" } - - expect(llm.name).to eq("claude-opus-4-0") - expect(llm.url).to eq(preset[:endpoint]) - expect(llm.tokenizer).to eq(preset[:tokenizer].to_s) - expect(llm.max_prompt_tokens.to_i).to eq(model_preset[:tokens]) - expect(llm.provider).to eq("anthropic") - expect(llm.display_name).to eq(model_preset[:display_name]) - expect(llm.user_id).not_to be_nil - end - - it "manually configures an LLM" do - llm_count = LlmModel.count - - visit "/admin/plugins/discourse-ai/ai-llms" - - expect(page_header).to be_visible - - find("[data-llm-id='none'] button").click() - - expect(page_header).to be_hidden - - form.field("display_name").fill_in("Self-hosted LLM") - form.field("name").fill_in("llava-hf/llava-v1.6-mistral-7b-hf") - form.field("url").fill_in("srv://self-hostest.test") - form.field("api_key").fill_in("1234") - form.field("max_prompt_tokens").fill_in(8000) - form.field("provider").select("vllm") - form.field("tokenizer").select("DiscourseAi::Tokenizer::Llama3Tokenizer") - form.field("max_output_tokens").fill_in(2000) - form.field("vision_enabled").toggle - form.field("enabled_chat_bot").toggle - form.submit - - expect(page).to have_current_path(%r{/admin/plugins/discourse-ai/ai-llms/\d+/edit}) - llm = LlmModel.order(:id).last - expect(llm.max_output_tokens.to_i).to eq(2000) - - expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-llms/#{llm.id}/edit") - - form.field("max_output_tokens").fill_in(2001) - form.submit - - # should go to llm list and see the llms correctly configured - page.go_back - - expect(page).to have_selector(".ai-llms-list-editor__configured .ai-llm-list__row", count: 1) - - llm.reload - expect(llm.display_name).to eq("Self-hosted LLM") - expect(llm.name).to eq("llava-hf/llava-v1.6-mistral-7b-hf") - expect(llm.url).to eq("srv://self-hostest.test") - expect(llm.tokenizer).to eq("DiscourseAi::Tokenizer::Llama3Tokenizer") - expect(llm.max_prompt_tokens.to_i).to eq(8000) - expect(llm.provider).to eq("vllm") - expect(llm.max_output_tokens.to_i).to eq(2001) - expect(llm.vision_enabled).to eq(true) - expect(llm.user_id).not_to be_nil - - expect(LlmModel.count).to eq(llm_count + 1) - end - - context "when changing the provider" do - it "has the correct provider params when visiting the edit page" do - llm = - Fabricate(:llm_model, provider: "anthropic", provider_params: { enable_reasoning: true }) - visit "/admin/plugins/discourse-ai/ai-llms/#{llm.id}/edit" - - expect(form).to have_field_with_name("provider_params.disable_native_tools") - expect(form).to have_field_with_name("provider_params.reasoning_tokens") - - reasoning = form.field("provider_params.enable_reasoning") - expect(reasoning).to be_checked - end - it "correctly changes the provider params" do - visit "/admin/plugins/discourse-ai/ai-llms" - find("[data-llm-id='none'] button").click() - form.field("provider").select("vllm") - - expect(form).to have_field_with_name("provider_params.disable_system_prompt") - expect(form).to have_no_field_with_name("provider_params.disable_native_tools") - - form.field("provider").select("open_router") - - expect(form).to have_field_with_name("provider_params.disable_streaming") - expect(form).to have_field_with_name("provider_params.disable_native_tools") - end - - it "updates if the url can be edited" do - visit "/admin/plugins/discourse-ai/ai-llms" - find("[data-llm-id='none'] button").click() - form.field("provider").select("vllm") - - expect(form).to have_field_with_name("url") - - form.field("provider").select("aws_bedrock") - - expect(form).to have_no_field_with_name("url") - end - end - - context "with quotas" do - fab!(:llm_model_1) { Fabricate(:llm_model, name: "claude-2") } - fab!(:group_1) { Fabricate(:group) } - - before { Fabricate(:llm_quota, group: group_1, llm_model: llm_model_1, max_tokens: 1000) } - - it "prefills the quotas form" do - visit "/admin/plugins/discourse-ai/ai-llms/#{llm_model_1.id}/edit" - - expect(page).to have_selector( - ".ai-llm-quotas__table .ai-llm-quotas__cell", - text: group_1.name, - ) - end - - it "can remove a quota" do - visit "/admin/plugins/discourse-ai/ai-llms/#{llm_model_1.id}/edit" - - find(".ai-llm-quotas__delete-btn:nth-child(1)").click - - expect(page).to have_no_selector( - ".ai-llm-quotas__table .ai-llm-quotas__cell", - text: group_1.name, - ) - end - - it "can add a quota" do - visit "/admin/plugins/discourse-ai/ai-llms/#{llm_model_1.id}/edit" - find(".ai-llm-editor__add-quota-btn").click - select_kit = PageObjects::Components::SelectKit.new(".group-chooser") - select_kit.expand - select_kit.select_row_by_value(1) - form = PageObjects::Components::FormKit.new(".ai-llm-quota-modal form") - form.field("max_tokens").fill_in(2000) - form.submit - - expect(page).to have_selector( - ".ai-llm-quotas__table .ai-llm-quotas__cell", - text: Group.find(1).name, - ) - end - end - - context "when seeded LLM is present" do - fab!(:llm_model) { Fabricate(:seeded_model) } - - it "shows the provider as CDCK in the UI" do - visit "/admin/plugins/discourse-ai/ai-llms" - expect(page).to have_css( - "[data-llm-id='cdck-hosted']", - text: I18n.t("js.discourse_ai.llms.providers.CDCK"), - ) - end - - it "seeded LLM has a description" do - visit "/admin/plugins/discourse-ai/ai-llms" - - desc = I18n.t("js.discourse_ai.llms.preseeded_model_description", model: llm_model.name) - - expect(page).to have_css( - "[data-llm-id='#{llm_model.name}'] .ai-llm-list__description", - text: desc, - ) - end - - it "seeded LLM has a disabled edit button" do - visit "/admin/plugins/discourse-ai/ai-llms" - expect(page).to have_css("[data-llm-id='cdck-hosted'] .ai-llm-list__edit-disabled-tooltip") - end - end -end diff --git a/spec/system/page_objects/components/ai_caption_popup.rb b/spec/system/page_objects/components/ai_caption_popup.rb deleted file mode 100644 index 7f48536a..00000000 --- a/spec/system/page_objects/components/ai_caption_popup.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Components - class AiCaptionPopup < PageObjects::Components::Base - GENERATE_CAPTION_SELECTOR = ".button-wrapper .generate-caption" - CAPTION_POPUP_SELECTOR = ".ai-caption-popup" - CAPTION_TEXTAREA_SELECTOR = "#{CAPTION_POPUP_SELECTOR} textarea" - - def hover_image_wrapper - image_wrapper = find(".d-editor-preview .image-wrapper") - image_wrapper.hover - end - - def click_generate_caption - hover_image_wrapper - page.find(GENERATE_CAPTION_SELECTOR, visible: false).click - end - - def has_caption_popup_value?(value) - page.find(CAPTION_TEXTAREA_SELECTOR).value == value - end - - def save_caption - hover_image_wrapper - find("#{CAPTION_POPUP_SELECTOR} .btn-primary").click - end - - def cancel_caption - hover_image_wrapper - find("#{CAPTION_POPUP_SELECTOR} .cancel-request").click - end - - def has_no_disabled_generate_button? - page.has_no_css?("#{GENERATE_CAPTION_SELECTOR}.disabled", visible: false) - end - - def has_no_generate_caption_button? - page.has_no_css?(GENERATE_CAPTION_SELECTOR, visible: false) - end - end - end -end diff --git a/spec/system/page_objects/components/ai_composer_helper_menu.rb b/spec/system/page_objects/components/ai_composer_helper_menu.rb deleted file mode 100644 index 1cd657a3..00000000 --- a/spec/system/page_objects/components/ai_composer_helper_menu.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Components - class AiComposerHelperMenu < PageObjects::Components::Base - COMPOSER_EDITOR_SELECTOR = ".d-editor-input" - CONTEXT_MENU_SELECTOR = ".ai-composer-helper-menu" - OPTIONS_STATE_SELECTOR = ".ai-helper-options" - LOADING_STATE_SELECTOR = ".ai-helper-loading" - CUSTOM_PROMPT_SELECTOR = "#{CONTEXT_MENU_SELECTOR} .ai-custom-prompt" - CUSTOM_PROMPT_INPUT_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__input" - CUSTOM_PROMPT_BUTTON_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__submit" - - def select_helper_model(mode) - find( - "#{OPTIONS_STATE_SELECTOR} li[data-value=\"#{mode}\"] .ai-helper-options__button", - ).click - end - - def press_undo_keys - find(COMPOSER_EDITOR_SELECTOR).send_keys([PLATFORM_KEY_MODIFIER, "z"]) - end - - def press_escape_key - find("body").send_keys(:escape) - end - - def click_custom_prompt_button - find(CUSTOM_PROMPT_BUTTON_SELECTOR).click - end - - def fill_custom_prompt(content) - find(CUSTOM_PROMPT_INPUT_SELECTOR).fill_in(with: content) - self - end - - def has_context_menu? - page.has_css?(CONTEXT_MENU_SELECTOR) - end - - def has_no_context_menu? - page.has_no_css?(CONTEXT_MENU_SELECTOR) - end - - def showing_options? - page.has_css?(OPTIONS_STATE_SELECTOR) - end - - def showing_loading? - page.has_css?(LOADING_STATE_SELECTOR) - end - - def has_custom_prompt? - page.has_css?(CUSTOM_PROMPT_SELECTOR) - end - - def has_no_custom_prompt? - page.has_no_css?(CUSTOM_PROMPT_SELECTOR) - end - - def has_custom_prompt_button? - page.has_css?(CUSTOM_PROMPT_BUTTON_SELECTOR) - end - - def has_no_custom_prompt_button? - page.has_no_css?(CUSTOM_PROMPT_BUTTON_SELECTOR) - end - - def has_custom_prompt_button_enabled? - page.has_css?("#{CUSTOM_PROMPT_BUTTON_SELECTOR}:not(:disabled)") - end - - def has_custom_prompt_button_disabled? - page.has_css?("#{CUSTOM_PROMPT_BUTTON_SELECTOR}:disabled") - end - end - end -end diff --git a/spec/system/page_objects/components/ai_pm_homepage.rb b/spec/system/page_objects/components/ai_pm_homepage.rb deleted file mode 100644 index 06773b05..00000000 --- a/spec/system/page_objects/components/ai_pm_homepage.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Components - class AiPmHomepage < PageObjects::Components::Base - HOMEPAGE_WRAPPER_CLASS = ".ai-bot-conversations__content-wrapper" - - def visit - page.visit("/discourse-ai/ai-bot/conversations") - end - - def input - page.find("#ai-bot-conversations-input") - end - - def submit - page.find(".ai-conversation-submit").click - end - - def has_too_short_dialog? - page.find( - ".dialog-content", - text: - I18n.t( - "js.discourse_ai.ai_bot.conversations.min_input_length_message", - count: SiteSetting.min_personal_message_post_length, - ), - ) - end - - def has_homepage? - page.has_css?(HOMEPAGE_WRAPPER_CLASS) - end - - def has_no_homepage? - page.has_no_css?(HOMEPAGE_WRAPPER_CLASS) - end - - def has_no_new_question_button? - page.has_no_css?(".ai-new-question-button") - end - - def has_new_question_button? - sidebar = PageObjects::Components::NavigationMenu::Sidebar.new - sidebar.has_css?( - "button.ai-new-question-button", - text: I18n.t("js.discourse_ai.ai_bot.conversations.new"), - ) - end - - def click_new_question_button - page.find(".ai-new-question-button").click - end - - def click_fist_sidebar_conversation - page.find( - ".sidebar-section-content a.sidebar-section-link", - ).click - end - - def persona_selector - PageObjects::Components::SelectKit.new(".persona-llm-selector__persona-dropdown") - end - - def llm_selector - PageObjects::Components::SelectKit.new(".persona-llm-selector__llm-dropdown") - end - - def has_sidebar_back_link? - page.has_css?(".sidebar-sections__back-to-forum") - end - - def has_no_sidebar_back_link? - page.has_no_css?(".sidebar-sections__back-to-forum") - end - end - end -end diff --git a/spec/system/page_objects/components/ai_post_helper_menu.rb b/spec/system/page_objects/components/ai_post_helper_menu.rb deleted file mode 100644 index 5a907604..00000000 --- a/spec/system/page_objects/components/ai_post_helper_menu.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Components - class AiPostHelperMenu < PageObjects::Components::Base - POST_SELECTION_TOOLBAR_SELECTOR = ".quote-button" - QUOTE_SELECTOR = ".insert-quote" - EDIT_SELECTOR = ".quote-edit-label" - SHARE_SELECTOR = ".quote-sharing" - - AI_HELPER_SELECTOR = ".ai-post-helper" - TRIGGER_SELECTOR = "#{AI_HELPER_SELECTOR}__trigger" - OPTIONS_SELECTOR = ".ai-helper-options" - LOADING_SELECTOR = ".ai-helper-context-menu__loading" - SUGGESTION_SELECTOR = "#{AI_HELPER_SELECTOR}__suggestion" - HIGHLIGHT_SELECTOR = ".ai-helper-highlighted-selection" - - def click_ai_button - find(TRIGGER_SELECTOR).click - end - - def click_add_footnote - find("#{SUGGESTION_SELECTOR}__insert-footnote").click - end - - def select_helper_model(mode) - find("#{OPTIONS_SELECTOR} .btn[data-name=\"#{mode}\"]").click - end - - def has_suggestion_value?(value) - page.has_css?("#{SUGGESTION_SELECTOR}__text", text: value) - end - - def suggestion_value - find("#{SUGGESTION_SELECTOR}__text").text - end - - def has_highlighted_text? - page.has_css?(HIGHLIGHT_SELECTOR) - end - - def has_no_highlighted_text? - page.has_no_css?(HIGHLIGHT_SELECTOR) - end - - def has_mobile_post_ai_helper? - page.has_css?(".fk-d-menu-modal #{AI_HELPER_SELECTOR}") - end - - def has_no_mobile_post_ai_helper? - page.has_no_css?(".fk-d-menu-modal #{AI_HELPER_SELECTOR}") - end - - def has_post_ai_helper? - page.has_css?(AI_HELPER_SELECTOR) - end - - def has_no_post_ai_helper? - page.has_no_css?(AI_HELPER_SELECTOR) - end - - def has_post_ai_helper_options? - page.has_css?(OPTIONS_SELECTOR) - end - - def has_no_post_ai_helper_options? - page.has_no_css?(OPTIONS_SELECTOR) - end - - def has_post_selection_toolbar? - page.has_css?(POST_SELECTION_TOOLBAR_SELECTOR) - end - - def has_no_post_selection_toolbar? - page.has_no_css?(POST_SELECTION_TOOLBAR_SELECTOR) - end - - def has_post_selection_primary_buttons? - page.has_css?(QUOTE_SELECTOR) || page.has_css?(EDIT_SELECTOR) || - page.has_css?(SHARE_SELECTOR) - end - - def has_no_post_selection_primary_buttons? - page.has_no_css?(QUOTE_SELECTOR) || page.has_no_css?(EDIT_SELECTOR) || - page.has_no_css?(SHARE_SELECTOR) - end - - def has_suggestions? - page.has_css?(SUGGESTION_SELECTOR) - end - end - end -end diff --git a/spec/system/page_objects/components/ai_split_topic_suggester.rb b/spec/system/page_objects/components/ai_split_topic_suggester.rb deleted file mode 100644 index 528ba74e..00000000 --- a/spec/system/page_objects/components/ai_split_topic_suggester.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Components - class AiSplitTopicSuggester < PageObjects::Components::Base - SUGGESTION_BUTTON_SELECTOR = ".ai-split-topic-suggestion-button" - TITLE_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}[data-suggestion-mode='suggest_title']" - CATEGORY_BUTTON_SELECTOR = - "#{SUGGESTION_BUTTON_SELECTOR}[data-suggestion-mode='suggest_category']" - TAG_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}[data-suggestion-mode='suggest_tags']" - MENU_SELECTOR = ".fk-d-menu[data-identifier='ai-split-topic-suggestion-menu']" - - def click_suggest_titles_button - page.find(TITLE_BUTTON_SELECTOR, visible: :all).click - end - - def click_suggest_category_button - find(CATEGORY_BUTTON_SELECTOR, visible: :all).click - end - - def click_suggest_tags_button - find(TAG_BUTTON_SELECTOR, visible: :all).click - end - - def select_suggestion_by_value(index) - find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]").click - end - - def select_suggestion_by_name(name) - find("#{MENU_SELECTOR} li[data-name=\"#{name}\"]").click - end - - def suggestion_name(index) - suggestion = find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]") - suggestion["data-name"] - end - - def has_dropdown? - has_css?(MENU_SELECTOR) - end - - def has_no_dropdown? - has_no_css?(MENU_SELECTOR) - end - - def has_suggestion_button? - has_css?(SUGGESTION_BUTTON_SELECTOR) - end - - def has_no_suggestion_button? - has_no_css?(SUGGESTION_BUTTON_SELECTOR) - end - end - end -end diff --git a/spec/system/page_objects/components/ai_suggestion_dropdown.rb b/spec/system/page_objects/components/ai_suggestion_dropdown.rb deleted file mode 100644 index 5d061dad..00000000 --- a/spec/system/page_objects/components/ai_suggestion_dropdown.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Components - class AiSuggestionDropdown < PageObjects::Components::Base - SUGGESTION_BUTTON_SELECTOR = ".suggestion-button" - TITLE_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}.suggest-titles-button" - CATEGORY_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}.suggest-category-button" - TAG_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}.suggest-tags-button" - MENU_SELECTOR = ".ai-suggestions-menu" - - def click_suggest_titles_button - page.find(TITLE_BUTTON_SELECTOR, visible: :all).click - end - - def click_suggest_category_button - find(CATEGORY_BUTTON_SELECTOR, visible: :all).click - end - - def click_suggest_tags_button - find(TAG_BUTTON_SELECTOR, visible: :all).click - end - - def select_suggestion_by_value(index) - find("#{MENU_SELECTOR} button[data-value=\"#{index}\"]").click - end - - def select_suggestion_by_name(name) - find("#{MENU_SELECTOR} button[data-name=\"#{name}\"]").click - end - - def suggestion_name(index) - suggestion = find("#{MENU_SELECTOR} button[data-value=\"#{index}\"]") - suggestion["data-name"] - end - - def has_dropdown? - has_css?(MENU_SELECTOR) - end - - def has_no_dropdown? - has_no_css?(MENU_SELECTOR) - end - - def has_suggestion_button? - has_css?(SUGGESTION_BUTTON_SELECTOR) - end - - def has_no_suggestion_button? - has_no_css?(SUGGESTION_BUTTON_SELECTOR) - end - end - end -end diff --git a/spec/system/page_objects/components/ai_summary_box.rb b/spec/system/page_objects/components/ai_summary_box.rb deleted file mode 100644 index 73bb5ee4..00000000 --- a/spec/system/page_objects/components/ai_summary_box.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Components - class AiSummaryTrigger < PageObjects::Components::Base - SUMMARY_BUTTON_SELECTOR = ".ai-summarization-button" - SUMMARY_CONTAINER_SELECTOR = ".ai-summary-modal" - - def click_summarize - find(SUMMARY_BUTTON_SELECTOR).click - end - - def click_regenerate_summary - find("#{SUMMARY_CONTAINER_SELECTOR} .d-modal__footer button").click - end - - def has_summary?(summary) - find("#{SUMMARY_CONTAINER_SELECTOR} .generated-summary p").text == summary - end - - def has_generating_summary_indicator? - find("#{SUMMARY_CONTAINER_SELECTOR} .ai-summary__generating-text").present? - end - end - end -end diff --git a/spec/system/page_objects/modals/ai_tool_test_modal.rb b/spec/system/page_objects/modals/ai_tool_test_modal.rb deleted file mode 100644 index 514581a1..00000000 --- a/spec/system/page_objects/modals/ai_tool_test_modal.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Modals - class AiToolTest < PageObjects::Modals::Base - BODY_SELECTOR = ".ai-tool-test-modal__body" - MODAL_SELECTOR = ".ai-tool-test-modal" - - def base_currency=(value) - body.fill_in("base_currency", with: value) - end - - def target_currency=(value) - body.fill_in("target_currency", with: value) - end - - def amount=(value) - body.fill_in("amount", with: value) - end - - def run_test - click_primary_button - end - end - end -end diff --git a/spec/system/page_objects/modals/diff_modal.rb b/spec/system/page_objects/modals/diff_modal.rb deleted file mode 100644 index 4c6a5a27..00000000 --- a/spec/system/page_objects/modals/diff_modal.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Modals - class DiffModal < PageObjects::Modals::Base - def visible? - page.has_css?(".composer-ai-helper-modal", wait: 5) - end - - def confirm_changes - find(".d-modal__footer button.confirm", wait: 5).click - end - - def discard_changes - find(".d-modal__footer button.discard", wait: 5).click - end - - def old_value - find(".composer-ai-helper-modal__old-value").text - end - - def new_value - find(".composer-ai-helper-modal__new-value").text - end - - def has_diff?(old_value, new_value) - has_css?(".inline-diff ins", text: new_value) && - has_css?(".inline-diff del", text: old_value) - end - end - end -end diff --git a/spec/system/page_objects/pages/admin_ai_features.rb b/spec/system/page_objects/pages/admin_ai_features.rb deleted file mode 100644 index e37c3b2a..00000000 --- a/spec/system/page_objects/pages/admin_ai_features.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Pages - class AdminAiFeatures < PageObjects::Pages::Base - FEATURES_PAGE = ".ai-features" - - def visit - page.visit("/admin/plugins/discourse-ai/ai-features") - self - end - - def toggle_configured - select = page.find("#{FEATURES_PAGE} .ai-features__controls .d-select") - select.find("option[value='configured']").select_option - end - - def toggle_unconfigured - select = page.find("#{FEATURES_PAGE} .ai-features__controls .d-select") - select.find("option[value='unconfigured']").select_option - end - - def has_listed_modules?(count) - page.has_css?("#{FEATURES_PAGE} .ai-module", count: count) - end - - def has_feature_persona?(feature_name, name) - page.has_css?( - "#{FEATURES_PAGE} .ai-feature-card[data-feature-name='#{feature_name}'] .ai-feature-card__persona-button .d-button-label", - text: name, - ) - end - - def has_feature_groups?(feature_name, groups) - listed_groups = - page.find( - "#{FEATURES_PAGE} .ai-feature-card[data-feature-name='#{feature_name}'] .ai-feature-card__item-groups", - ) - list_items = listed_groups.all("li", visible: true).map(&:text) - - list_items.sort == groups.sort - end - - def click_edit_module(module_name) - page.find("#{FEATURES_PAGE} .ai-module[data-module-name='#{module_name}'] .edit").click - end - end - end -end diff --git a/spec/system/page_objects/pages/discourse_ai/header.rb b/spec/system/page_objects/pages/discourse_ai/header.rb deleted file mode 100644 index 9a3e956a..00000000 --- a/spec/system/page_objects/pages/discourse_ai/header.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Pages - module DiscourseAi - class Header < ::PageObjects::Pages::Header - def click_bot_button - find(".ai-bot-button").click - end - - def has_icon_in_bot_button?(icon:) - page.has_css?(".ai-bot-button .d-icon-#{icon}") - end - end - end - end -end diff --git a/spec/system/page_objects/pages/user_preferences_ai.rb b/spec/system/page_objects/pages/user_preferences_ai.rb deleted file mode 100644 index c4c599f2..00000000 --- a/spec/system/page_objects/pages/user_preferences_ai.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module PageObjects - module Pages - class UserPreferencesAi < PageObjects::Pages::Base - def visit(user) - page.visit("/u/#{user.username}/preferences/ai") - self - end - - def has_ai_preference_checked?(preference) - page.find(".#{preference} input").checked? - end - - def has_ai_preference?(preference) - page.has_css?(".#{preference} input") - end - - def has_no_ai_preference?(preference) - page.has_no_css?(".#{preference} input") - end - - def toggle_setting(preference) - page.find(".#{preference} input").click - end - - def save_changes - page.find(".save-changes").click - end - end - end -end diff --git a/spec/system/summarization/chat_summarization_spec.rb b/spec/system/summarization/chat_summarization_spec.rb deleted file mode 100644 index 8f9f340a..00000000 --- a/spec/system/summarization/chat_summarization_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Summarize a channel since your last visit", type: :system do - fab!(:current_user) { Fabricate(:user) } - fab!(:group) - fab!(:channel) { Fabricate(:chat_channel) } - fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel) } - let(:chat) { PageObjects::Pages::Chat.new } - let(:summarization_result) { "This is a summary" } - - before do - enable_current_plugin - - group.add(current_user) - - assign_fake_provider_to(:ai_summarization_model) - assign_persona_to(:ai_summarization_persona, [group.id]) - SiteSetting.ai_summarization_enabled = true - - SiteSetting.chat_enabled = true - SiteSetting.chat_allowed_groups = group.id.to_s - sign_in(current_user) - chat_system_bootstrap(current_user, [channel]) - end - - it "displays a summary of the messages since the selected timeframe" do - DiscourseAi::Completions::Llm.with_prepared_responses([summarization_result]) do - chat.visit_channel(channel) - - find(".chat-composer-dropdown__trigger-btn").click - find(".chat-composer-dropdown__action-btn.channel-summary").click - - expect(page.has_css?(".chat-modal-channel-summary")).to eq(true) - - find(".summarization-since").click - find(".select-kit-row[data-value=\"3\"]").click - - expect(find(".summary-area").text).to eq(summarization_result) - end - end -end diff --git a/spec/system/summarization/topic_summarization_spec.rb b/spec/system/summarization/topic_summarization_spec.rb deleted file mode 100644 index b3d75880..00000000 --- a/spec/system/summarization/topic_summarization_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Summarize a topic ", type: :system do - fab!(:current_user) { Fabricate(:user) } - fab!(:group) - fab!(:topic) - fab!(:post) do - Fabricate( - :post, - topic: topic, - raw: - "I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.", - ) - end - - let(:summarization_result) { "This is a summary" } - - let(:topic_page) { PageObjects::Pages::Topic.new } - let(:summary_box) { PageObjects::Components::AiSummaryTrigger.new } - - fab!(:ai_summary) { Fabricate(:ai_summary, target: topic, summarized_text: "This is a summary") } - - before do - enable_current_plugin - - group.add(current_user) - - assign_fake_provider_to(:ai_summarization_model) - assign_persona_to(:ai_summarization_persona, [group.id]) - SiteSetting.ai_summarization_enabled = true - - sign_in(current_user) - end - - context "when a summary is cached" do - it "displays it" do - topic_page.visit_topic(topic) - summary_box.click_summarize - expect(summary_box).to have_summary(summarization_result) - end - end - - context "when a summary is outdated" do - fab!(:new_post) do - Fabricate( - :post, - topic: topic, - raw: - "Idk, I think pie is overrated. I prefer cake. Cake is the best dessert. I always eat cake. I never throw it at people.", - ) - end - - it "displays the new summary instead of a cached one" do - topic_page.visit_topic(topic) - summary_box.click_summarize - summary_box.click_regenerate_summary - expect(summary_box).to have_generating_summary_indicator - end - end -end diff --git a/spec/tasks/backfill_spec.rb b/spec/tasks/backfill_spec.rb deleted file mode 100644 index aa002b8b..00000000 --- a/spec/tasks/backfill_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require_relative "../support/sentiment_inference_stubs" - -RSpec.describe "ai:sentiment:backfill" do - before do - enable_current_plugin - Rake::Task.clear - Discourse::Application.load_tasks - end - - before do - SiteSetting.ai_sentiment_model_configs = - "[{\"model_name\":\"SamLowe/roberta-base-go_emotions\",\"endpoint\":\"http://samlowe-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"j-hartmann/emotion-english-distilroberta-base\",\"endpoint\":\"http://jhartmann-emotion.com\",\"api_key\":\"123\"},{\"model_name\":\"cardiffnlp/twitter-roberta-base-sentiment-latest\",\"endpoint\":\"http://cardiffnlp-sentiment.com\",\"api_key\":\"123\"}]" - end - - it "does nothing if the topic is soft-deleted" do - target = Fabricate(:post) - SentimentInferenceStubs.stub_classification(target) - target.topic.trash! - - Rake::Task["ai:sentiment:backfill"].invoke - - expect(ClassificationResult.count).to be_zero - end -end diff --git a/spec/tasks/scan_spec.rb b/spec/tasks/scan_spec.rb deleted file mode 100644 index a6876419..00000000 --- a/spec/tasks/scan_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "ai:spam rake tasks" do - let!(:topic1) { Fabricate(:topic, created_at: 2.days.ago) } - let!(:post1) { Fabricate(:post, topic: topic1, created_at: 2.days.ago) } - let!(:topic2) { Fabricate(:topic, created_at: 1.hour.ago) } - let!(:post2) { Fabricate(:post, topic: topic2, created_at: 1.hour.ago) } - - before { enable_current_plugin } - - describe "ai:spam:scan_posts" do - it "enqueues posts within date range" do - freeze_time do - start_date = 1.day.ago.to_s - end_date = Time.now.to_s - - expect_enqueued_with(job: :ai_spam_scan, args: { post_id: post2.id }) do - Rake::Task["ai:spam:scan_posts"].invoke(start_date, end_date) - end - - expect_not_enqueued_with(job: :ai_spam_scan, args: { post_id: post1.id }) - end - end - end - - describe "ai:spam:scan_topics" do - it "enqueues first posts of topics within date range" do - freeze_time do - start_date = 1.day.ago.to_s - end_date = Time.now.to_s - - expect_enqueued_with(job: :ai_spam_scan, args: { post_id: topic2.first_post.id }) do - Rake::Task["ai:spam:scan_topics"].invoke(start_date, end_date) - end - - expect_not_enqueued_with(job: :ai_spam_scan, args: { post_id: topic1.first_post.id }) - end - end - end -end diff --git a/stylelint.config.mjs b/stylelint.config.mjs deleted file mode 100644 index 990ebbd1..00000000 --- a/stylelint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -export default { - extends: ["@discourse/lint-configs/stylelint"], -}; diff --git a/svg-icons/icons-sprite.svg b/svg-icons/icons-sprite.svg deleted file mode 100644 index 9d0511c2..00000000 --- a/svg-icons/icons-sprite.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/javascripts/.gitkeep b/test/javascripts/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/test/javascripts/acceptance/post-helper-menu-test.js b/test/javascripts/acceptance/post-helper-menu-test.js deleted file mode 100644 index 1f17d8f7..00000000 --- a/test/javascripts/acceptance/post-helper-menu-test.js +++ /dev/null @@ -1,112 +0,0 @@ -import { click, settled, visit } from "@ember/test-helpers"; -import { test } from "qunit"; -import { AUTO_GROUPS } from "discourse/lib/constants"; -import { cloneJSON } from "discourse/lib/object"; -import topicFixtures from "discourse/tests/fixtures/topic"; -import { - acceptance, - publishToMessageBus, - query, - selectText, -} from "discourse/tests/helpers/qunit-helpers"; -import aiHelperPrompts from "../fixtures/ai-helper-prompts"; - -acceptance("AI Helper - Post Helper Menu", function (needs) { - needs.settings({ - discourse_ai_enabled: true, - ai_helper_enabled: true, - post_ai_helper_allowed_groups: "1|2", - ai_helper_enabled_features: "suggestions|context_menu", - share_quote_visibility: "anonymous", - enable_markdown_footnotes: true, - display_footnotes_inline: true, - }); - needs.user({ - admin: true, - moderator: true, - groups: [AUTO_GROUPS.admins], - can_use_assistant_in_post: true, - ai_helper_prompts: aiHelperPrompts, - trust_level: 4, - }); - needs.pretender((server, helper) => { - server.get("/t/1.json", () => { - const json = cloneJSON(topicFixtures["/t/28830/1.json"]); - json.post_stream.posts[0].can_edit_post = true; - json.post_stream.posts[0].can_edit = true; - return helper.response(json); - }); - - server.get("/t/2.json", () => { - const json = cloneJSON(topicFixtures["/t/28830/1.json"]); - json.post_stream.posts[0].cooked = - "

    La lluvia en España se queda principalmente en el avión.

    "; - return helper.response(json); - }); - - server.post(`/discourse-ai/ai-helper/stream_suggestion/`, () => { - return helper.response({ - result: "This is a suggestio", - done: false, - progress_channel: "/some/progress/channel", - }); - }); - - server.get("/discourse-ai/ai-bot/conversations.json", () => {}); - }); - - test("displays streamed explanation", async function (assert) { - await visit("/t/-/1"); - const suggestion = "This is a suggestion that is completed"; - const textNode = query("#post_1 .cooked p").childNodes[0]; - await selectText(textNode, 9); - await click(".ai-post-helper__trigger"); - await click(".ai-helper-options__button[data-name='explain']"); - await publishToMessageBus(`/some/progress/channel`, { - done: true, - result: suggestion, - }); - assert.dom(".ai-post-helper__suggestion__text").hasText(suggestion); - }); - - async function selectSpecificText(textNode, start, end) { - const range = document.createRange(); - range.setStart(textNode, start); - range.setEnd(textNode, end); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - const event = new PointerEvent("pointerup"); - document.dispatchEvent(event); - await settled(); - } - - test("adds explained text as footnote to post", async function (assert) { - await visit("/t/-/1"); - const suggestion = "This is a suggestion that is completed"; - - const textNode = query("#post_1 .cooked p").childNodes[0]; - await selectSpecificText(textNode, 72, 77); - await click(".ai-post-helper__trigger"); - await click(".ai-helper-options__button[data-name='explain']"); - await publishToMessageBus(`/some/progress/channel`, { - done: true, - result: suggestion, - }); - - assert.dom(".ai-post-helper__suggestion__insert-footnote").isDisabled(); - }); - - test("shows translated post", async function (assert) { - await visit("/t/-/2"); - const translated = "The rain in Spain, stays mainly in the Plane."; - await selectText(query("#post_1 .cooked p")); - await click(".ai-post-helper__trigger"); - await click(".ai-helper-options__button[data-name='translate']"); - await publishToMessageBus(`/some/progress/channel`, { - done: true, - result: translated, - }); - assert.dom(".ai-post-helper__suggestion__text").hasText(translated); - }); -}); diff --git a/test/javascripts/acceptance/topic-summary-test.js b/test/javascripts/acceptance/topic-summary-test.js deleted file mode 100644 index be8a3e87..00000000 --- a/test/javascripts/acceptance/topic-summary-test.js +++ /dev/null @@ -1,155 +0,0 @@ -import { click, visit } from "@ember/test-helpers"; -import { test } from "qunit"; -import { cloneJSON } from "discourse/lib/object"; -import topicFixtures from "discourse/tests/fixtures/topic"; -import { - acceptance, - publishToMessageBus, - updateCurrentUser, -} from "discourse/tests/helpers/qunit-helpers"; - -acceptance("Topic - Summary", function (needs) { - const currentUserId = 5; - - needs.user(); - needs.pretender((server, helper) => { - server.get("/t/1.json", () => { - const json = cloneJSON(topicFixtures["/t/130.json"]); - json.id = 1; - json.summarizable = true; - - return helper.response(json); - }); - - server.get("/discourse-ai/summarization/t/1", () => { - return helper.response({ - ai_topic_summary: { - summarized_text: "This a", - }, - done: false, - }); - }); - - server.get("/discourse-ai/ai-bot/conversations.json", () => {}); - }); - - needs.hooks.beforeEach(() => { - updateCurrentUser({ id: currentUserId }); - }); - - test("displays streamed summary", async function (assert) { - await visit("/t/-/1"); - - const partialSummary = "This a"; - await publishToMessageBus("/discourse-ai/summaries/topic/1", { - done: false, - ai_topic_summary: { summarized_text: partialSummary }, - }); - - await click(".ai-summarization-button"); - - assert - .dom(".ai-summary-box .generated-summary p") - .hasText(partialSummary, "Updates the summary with a partial result"); - - const finalSummary = "This is a completed summary"; - await publishToMessageBus("/discourse-ai/summaries/topic/1", { - done: true, - ai_topic_summary: { - summarized_text: finalSummary, - summarized_on: "2023-01-01T04:00:00.000Z", - algorithm: "OpenAI GPT-4", - outdated: false, - new_posts_since_summary: false, - can_regenerate: true, - }, - }); - - assert - .dom(".ai-summary-box .generated-summary p") - .hasText(finalSummary, "Updates the summary with a final result"); - - assert - .dom(".ai-summary-modal .summarized-on") - .exists("summary metadata exists"); - }); - - test("clicking summary links", async function (assert) { - await visit("/t/-/1"); - - const partialSummary = "In this post,"; - await publishToMessageBus("/discourse-ai/summaries/topic/1", { - done: false, - ai_topic_summary: { summarized_text: partialSummary }, - }); - - await click(".ai-summarization-button"); - const finalSummaryCooked = - "In this post, bianca said some stuff."; - const finalSummaryResult = "In this post, bianca said some stuff."; - await publishToMessageBus("/discourse-ai/summaries/topic/1", { - done: true, - ai_topic_summary: { - summarized_text: finalSummaryCooked, - summarized_on: "2023-01-01T04:00:00.000Z", - algorithm: "OpenAI GPT-4", - outdated: false, - new_posts_since_summary: false, - can_regenerate: true, - }, - }); - - await click(".generated-summary a"); - assert - .dom(".ai-summary-box .generated-summary p") - .hasText(finalSummaryResult, "Retains final summary after clicking link"); - }); -}); - -acceptance("Topic - Summary - Anon", function (needs) { - const finalSummary = "This is a completed summary"; - - needs.pretender((server, helper) => { - server.get("/t/1.json", () => { - const json = cloneJSON(topicFixtures["/t/280/1.json"]); - json.id = 1; - json.summarizable = true; - - return helper.response(json); - }); - - server.get("/discourse-ai/summarization/t/1", () => { - return helper.response({ - ai_topic_summary: { - summarized_text: finalSummary, - summarized_on: "2023-01-01T04:00:00.000Z", - algorithm: "OpenAI GPT-4", - outdated: false, - new_posts_since_summary: false, - can_regenerate: false, - }, - }); - }); - }); - - test("displays cached summary immediately", async function (assert) { - await visit("/t/-/1"); - - await click(".ai-summarization-button"); - - assert - .dom(".ai-summary-box .generated-summary p") - .hasText(finalSummary, "Updates the summary with the result"); - - assert - .dom(".ai-summary-modal .summarized-on") - .exists("summary metadata exists"); - }); - - test("clicking outside of summary should not close the summary box", async function (assert) { - await visit("/t/-/1"); - await click(".ai-summarization-button"); - await click("#main-outlet-wrapper"); - assert.dom(".ai-summary-box").exists(); - }); -}); diff --git a/test/javascripts/fixtures/ai-helper-prompts.js b/test/javascripts/fixtures/ai-helper-prompts.js deleted file mode 100644 index 7fac2500..00000000 --- a/test/javascripts/fixtures/ai-helper-prompts.js +++ /dev/null @@ -1,51 +0,0 @@ -export default [ - { - name: "translate", - translated_name: "Translate to English (US)", - prompt_type: "text", - icon: "language", - location: ["composer", "post"], - }, - { - name: "proofread", - translated_name: "Proofread text", - prompt_type: "diff", - icon: "spell-check", - location: ["composer", "post"], - }, - { - name: "markdown_table", - translated_name: "Generate Markdown table", - prompt_type: "diff", - icon: "table", - location: ["composer"], - }, - { - name: "custom_prompt", - translated_name: "Custom Prompt", - prompt_type: "diff", - icon: "comment", - location: ["composer", "post"], - }, - { - name: "explain", - translated_name: "Explain", - prompt_type: "text", - icon: "question", - location: ["post"], - }, - { - name: "generate_titles", - translated_name: "Suggest topic titles", - prompt_type: "list", - icon: "heading", - location: ["composer"], - }, - { - name: "illustrate_post", - translated_name: "Illustrate Post", - prompt_type: "list", - icon: "images", - location: ["composer"], - }, -]; diff --git a/test/javascripts/integration/components/ai-indicator-wave-test.gjs b/test/javascripts/integration/components/ai-indicator-wave-test.gjs deleted file mode 100644 index 54038a25..00000000 --- a/test/javascripts/integration/components/ai-indicator-wave-test.gjs +++ /dev/null @@ -1,18 +0,0 @@ -import { render } from "@ember/test-helpers"; -import { module, test } from "qunit"; -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import AiIndicatorWave from "discourse/plugins/discourse-ai/discourse/components/ai-indicator-wave"; - -module("Integration | Component | ai-indicator-wave", function (hooks) { - setupRenderingTest(hooks); - - test("it renders an indicator wave", async function (assert) { - await render(); - assert.dom(".ai-indicator-wave").exists(); - }); - - test("it does not render the indicator wave when loading is false", async function (assert) { - await render(); - assert.dom(".ai-indicator-wave").doesNotExist(); - }); -}); diff --git a/test/javascripts/unit/lib/ai-streamer-test.js b/test/javascripts/unit/lib/ai-streamer-test.js deleted file mode 100644 index 8ad1ab1d..00000000 --- a/test/javascripts/unit/lib/ai-streamer-test.js +++ /dev/null @@ -1,168 +0,0 @@ -import { module, test } from "qunit"; -import { - addProgressDot, - applyProgress, - MIN_LETTERS_PER_INTERVAL, -} from "discourse/plugins/discourse-ai/discourse/lib/ai-streamer/progress-handlers"; - -class FakeStreamUpdater { - constructor() { - this._streaming = true; - this._raw = ""; - this._cooked = ""; - this._element = document.createElement("div"); - } - - get streaming() { - return this._streaming; - } - - set streaming(value) { - this._streaming = value; - } - - get cooked() { - return this._cooked; - } - - get raw() { - return this._raw; - } - - async setRaw(value) { - this._raw = value; - // just fake it, calling cook is tricky - const cooked = `

    ${value}

    `; - await this.setCooked(cooked); - } - - async setCooked(value) { - this._cooked = value; - this._element.innerHTML = value; - } - - get element() { - return this._element; - } -} - -module("Discourse AI | Unit | Lib | ai-streamer", function () { - function confirmPlaceholder(html, expected, assert) { - const element = document.createElement("div"); - element.innerHTML = html; - - const expectedElement = document.createElement("div"); - expectedElement.innerHTML = expected; - - addProgressDot(element); - - assert.equal(element.innerHTML, expectedElement.innerHTML); - } - - test("inserts progress span in correct location for simple div", function (assert) { - const html = "
    hello world
    hello 2
    "; - const expected = - "
    hello world
    hello 2
    "; - - confirmPlaceholder(html, expected, assert); - }); - - test("inserts progress span in correct location for lists", function (assert) { - const html = "

    test

    • hello world
    • hello world
    "; - const expected = - "

    test

    • hello world
    • hello world
    "; - - confirmPlaceholder(html, expected, assert); - }); - - test("inserts correctly if list has blank html nodes", function (assert) { - const html = `
      -
    • Bold Text: To
    • - -
    `; - - const expected = `
      -
    • Bold Text: To
    • - -
    `; - - confirmPlaceholder(html, expected, assert); - }); - - test("inserts correctly for tables", function (assert) { - const html = ` - - - - - - - -
    Bananas20$0.50
    -`; - - const expected = ` - - - - - - - -
    Bananas20$0.50
    -`; - - confirmPlaceholder(html, expected, assert); - }); - - test("can perform delta updates", async function (assert) { - const status = { - startTime: Date.now(), - raw: "some raw content", - done: false, - }; - - const streamUpdater = new FakeStreamUpdater(); - - let done = await applyProgress(status, streamUpdater); - - assert.notOk(done, "The update should not be done."); - - assert.equal( - streamUpdater.raw, - status.raw.substring(0, MIN_LETTERS_PER_INTERVAL), - "The raw content should delta update." - ); - - done = await applyProgress(status, streamUpdater); - - assert.notOk(done, "The update should not be done."); - - assert.equal( - streamUpdater.raw, - status.raw.substring(0, MIN_LETTERS_PER_INTERVAL * 2), - "The raw content should delta update." - ); - - // last chunk - await applyProgress(status, streamUpdater); - - const innerHtml = streamUpdater.element.innerHTML; - assert.equal( - innerHtml, - "

    some raw content

    ", - "The cooked content should be updated." - ); - - status.done = true; - status.cooked = "

    updated cooked

    "; - - await applyProgress(status, streamUpdater); - - assert.equal( - streamUpdater.element.innerHTML, - "

    updated cooked

    ", - "The cooked content should be updated." - ); - }); -}); diff --git a/test/javascripts/unit/lib/diff-streamer-test.js b/test/javascripts/unit/lib/diff-streamer-test.js deleted file mode 100644 index d01c29c4..00000000 --- a/test/javascripts/unit/lib/diff-streamer-test.js +++ /dev/null @@ -1,67 +0,0 @@ -import { module, test } from "qunit"; -import DiffStreamer from "discourse/plugins/discourse-ai/discourse/lib/diff-streamer"; - -module("Unit | Lib | diff-streamer", function () { - test("streamingDiff correctly handles trivial cases", async function (assert) { - const originalText = "helo world"; - const targetText = "hello world"; - - const diffStreamer = new DiffStreamer(this.originalTextContent); - await diffStreamer.loadJSDiff(); - - const diffResult = diffStreamer.streamingDiff(originalText, targetText); - - const expectedDiff = [ - { count: 1, added: false, removed: true, value: "helo" }, - { count: 1, added: true, removed: false, value: "hello" }, - { count: 2, added: false, removed: false, value: " world" }, - ]; - - assert.deepEqual( - diffResult, - expectedDiff, - "Diff result should match the expected structure" - ); - }); - - test("streamingDiff correctly consolidates and handles diff drift", async function (assert) { - const originalText = - "This is todone, but I want to can why.\n\nWe\n\nSEO Tags supports a `canonical_url` override. I tried a few possibilities there. The one I wanted to work, ex: `https://www.discourse.org/de`, appended an extra `/de` on the URL, only in the deploy b"; - const targetText = "This is to-done"; - - const diffStreamer = new DiffStreamer(this.originalTextContent); - await diffStreamer.loadJSDiff(); - - const diffResult = diffStreamer.streamingDiff(originalText, targetText); - - // Verify the diff result is an array with the expected structure - assert.true(Array.isArray(diffResult), "Diff result should be an array"); - assert.strictEqual( - diffResult.length, - 3, - "Expecting exactly three parts in the diff result" - ); - - assert.strictEqual( - diffResult[0].value, - "This is ", - "First part should be unchanged" - ); - - assert.strictEqual( - diffResult[1].value, - "to-done", - "Second part should be an insertion" - ); - - assert.true(diffResult[1].added, "Second part should be an insertion"); - - assert.strictEqual( - diffResult[2].value.length, - 235, - "Third part should include all text" - ); - - assert.true(diffResult[2].removed, "Third part should be a removal"); - }); -}); diff --git a/test/javascripts/unit/lib/smooth-streamer-test.js b/test/javascripts/unit/lib/smooth-streamer-test.js deleted file mode 100644 index 411a4f61..00000000 --- a/test/javascripts/unit/lib/smooth-streamer-test.js +++ /dev/null @@ -1,106 +0,0 @@ -import { later } from "@ember/runloop"; -import { settled } from "@ember/test-helpers"; -import { setupTest } from "ember-qunit"; -import { module, test } from "qunit"; -import SmoothStreamer from "discourse/plugins/discourse-ai/discourse/lib/smooth-streamer"; - -module("Discourse AI | Unit | Lib | smooth-streamer", function (hooks) { - setupTest(hooks); - - test("it initializes correctly", function (assert) { - let mockText = ""; - const getRealtimeText = () => mockText; - const setRealtimeText = (val) => (mockText = val); - - const streamer = new SmoothStreamer(getRealtimeText, setRealtimeText); - - assert.false(streamer.isStreaming, "isStreaming should be false initially"); - assert.strictEqual( - streamer.renderedText, - "", - "renderedText should be empty initially" - ); - }); - - test("it streams text with animation", async function (assert) { - let mockText = ""; - const getRealtimeText = () => mockText; - const setRealtimeText = (val) => (mockText = val); - - const streamer = new SmoothStreamer(getRealtimeText, setRealtimeText); - const result1 = { text: "Hello", done: false }; - - await streamer.updateResult(result1, "text"); - assert.true( - streamer.isStreaming, - "isStreaming should be true while animating" - ); - - await settled(); - assert.strictEqual( - streamer.realtimeText, - "Hello", - "Realtime text should be updated immediately" - ); - assert.ok( - streamer.streamedText.length > 0, - "Streamed text should start appearing" - ); - - await new Promise((resolve) => later(resolve, 50)); - assert.strictEqual( - streamer.streamedText, - "Hello", - "Streamed text should fully appear" - ); - }); - - test("it stops streaming when done", async function (assert) { - let mockText = ""; - const getRealtimeText = () => mockText; - const setRealtimeText = (val) => (mockText = val); - - const streamer = new SmoothStreamer(getRealtimeText, setRealtimeText); - await streamer.updateResult({ text: "Done text", done: true }, "text"); - - assert.false(streamer.isStreaming, "isStreaming should be false when done"); - assert.strictEqual( - streamer.streamedText, - "Done text", - "Streamed text should be complete" - ); - assert.strictEqual( - streamer.realtimeText, - "Done text", - "Realtime text should match the final input" - ); - }); - - test("resetStreaming clears all progress", function (assert) { - let mockText = "Some text"; - const getRealtimeText = () => mockText; - const setRealtimeText = (val) => (mockText = val); - - const streamer = new SmoothStreamer(getRealtimeText, setRealtimeText); - streamer.streamedText = "Some text"; - streamer.streamedTextLength = 9; - streamer.isStreaming = true; - - streamer.resetStreaming(); - - assert.strictEqual( - streamer.streamedText, - "", - "Streamed text should be cleared" - ); - assert.strictEqual( - streamer.streamedTextLength, - 0, - "Streamed text length should be reset" - ); - assert.false( - streamer.isStreaming, - "isStreaming should be false after reset" - ); - }); -}); diff --git a/test/javascripts/unit/lib/utilities-test.js b/test/javascripts/unit/lib/utilities-test.js deleted file mode 100644 index f1cd5ed3..00000000 --- a/test/javascripts/unit/lib/utilities-test.js +++ /dev/null @@ -1,65 +0,0 @@ -import { module, test } from "qunit"; -import { jsonToHtml } from "discourse/plugins/discourse-ai/discourse/lib/utilities"; - -module("Unit | Utility | json-to-html", function () { - test("it properly handles nulls", function (assert) { - const input = null; - const result = jsonToHtml(input).toString(); - - assert.equal(result, "null", "Null should be properly formatted"); - }); - - test("it properly handles boolean", function (assert) { - const input = true; - const result = jsonToHtml(input).toString(); - - assert.equal(result, "true", "Boolean should be properly formatted"); - }); - - test("it properly handles numbers", function (assert) { - const input = 42.1; - const result = jsonToHtml(input).toString(); - - assert.equal(result, "42.1", "Numbers should be properly formatted"); - }); - - test("it properly handles undefined", function (assert) { - const input = undefined; - const result = jsonToHtml(input).toString(); - - assert.equal(result, "", "Undefined should be properly formatted"); - }); - - test("it handles nested objects correctly", function (assert) { - const input = { - outer: { - inner: { - key: "value", - }, - }, - }; - - const result = jsonToHtml(input).toString(); - const expected = - "
    • outer:
        • inner:
            • key: value
    "; - - assert.equal( - result, - expected, - "Nested objects should be properly formatted" - ); - }); - - test("it handles arrays correctly", function (assert) { - const input = { - array: [1, 2, 3], - }; - - const result = jsonToHtml(input).toString(); - - const expected = - "
    • array:
      • 0: 1
      • 1: 2
      • 2: 3
    "; - - assert.equal(result, expected, "Arrays should be properly formatted"); - }); -}); diff --git a/test/javascripts/unit/models/ai-persona-test.js b/test/javascripts/unit/models/ai-persona-test.js deleted file mode 100644 index a21d51a2..00000000 --- a/test/javascripts/unit/models/ai-persona-test.js +++ /dev/null @@ -1,74 +0,0 @@ -import { module, test } from "qunit"; -import AiPersona from "discourse/plugins/discourse-ai/discourse/admin/models/ai-persona"; - -module("Discourse AI | Unit | Model | ai-persona", function () { - test("toPOJO", function (assert) { - const properties = { - tools: [ - ["ToolName", { option1: "value1", option2: "value2" }, false], - "ToolName2", - "ToolName3", - ], - }; - - const aiPersonaPOJO = AiPersona.create(properties).toPOJO(); - - assert.deepEqual(aiPersonaPOJO.tools, [ - "ToolName", - "ToolName2", - "ToolName3", - ]); - assert.equal(aiPersonaPOJO.toolOptions["ToolName"].option1, "value1"); - assert.equal(aiPersonaPOJO.toolOptions["ToolName"].option2, "value2"); - }); - - test("fromPOJO", function (assert) { - const properties = { - id: 1, - name: "Test", - tools: [["ToolName", { option1: "value1" }, false]], - allowed_group_ids: [12], - system: false, - enabled: true, - system_prompt: "System Prompt", - priority: false, - description: "Description", - top_p: 0.8, - temperature: 0.7, - default_llm_id: 1, - force_default_llm: false, - user: null, - user_id: null, - max_context_posts: 5, - vision_enabled: true, - vision_max_pixels: 100, - rag_uploads: [], - rag_chunk_tokens: 374, - rag_chunk_overlap_tokens: 10, - rag_conversation_chunks: 10, - rag_llm_model_id: 1, - question_consolidator_llm_id: 2, - allow_chat: false, - tool_details: true, - forced_tool_count: -1, - allow_personal_messages: true, - allow_topic_mentions: true, - allow_chat_channel_mentions: true, - allow_chat_direct_messages: true, - }; - const updatedValue = "updated"; - - const aiPersona = AiPersona.create({ ...properties }); - - const personaPOJO = aiPersona.toPOJO(); - - personaPOJO.toolOptions["ToolName"].option1 = updatedValue; - personaPOJO.forcedTools = "ToolName"; - - const updatedPersona = aiPersona.fromPOJO(personaPOJO); - - assert.deepEqual(updatedPersona.tools, [ - ["ToolName", { option1: updatedValue }, true], - ]); - }); -}); diff --git a/test/javascripts/unit/routes/discourse-ai-shared-conversation-show-route-test.js b/test/javascripts/unit/routes/discourse-ai-shared-conversation-show-route-test.js deleted file mode 100644 index 65e89c30..00000000 --- a/test/javascripts/unit/routes/discourse-ai-shared-conversation-show-route-test.js +++ /dev/null @@ -1,61 +0,0 @@ -import { setupTest } from "ember-qunit"; -import { module, test } from "qunit"; -import sinon from "sinon"; - -module( - "Unit | Route | discourse-ai-shared-conversation-show", - function (hooks) { - setupTest(hooks); - - test("it redirects based on currentUser preference", function (assert) { - const transition = { - intent: { url: "https://www.discourse.org" }, - abort() { - assert.ok(true, "transition.abort() was called"); - }, - }; - - const route = this.owner.lookup( - "route:discourse-ai-shared-conversation-show" - ); - - const windowOpenStub = sinon.stub(window, "open"); - const routeRedirectStub = sinon.stub(route, "redirect"); - - // external_links_in_new_tab = true - route.set("currentUser", { - user_option: { - external_links_in_new_tab: true, - }, - }); - - windowOpenStub.callsFake((url, target) => { - assert.equal( - url, - "https://www.discourse.org", - "window.open was called with the correct URL" - ); - assert.equal(target, "_blank", 'window.open was called with "_blank"'); - }); - - route.beforeModel(transition); - - // external_links_in_new_tab = false - route.set("currentUser", { - user_option: { - external_links_in_new_tab: false, - }, - }); - - routeRedirectStub.callsFake((url) => { - assert.equal( - url, - "https://www.discourse.org", - "redirect was called with the correct URL" - ); - }); - - route.beforeModel(transition); - }); - } -); diff --git a/translator.yml b/translator.yml deleted file mode 100644 index bb099f87..00000000 --- a/translator.yml +++ /dev/null @@ -1,7 +0,0 @@ -# Configuration file for discourse-translator-bot - -files: - - source_path: config/locales/client.en.yml - destination_path: client.yml - - source_path: config/locales/server.en.yml - destination_path: server.yml