Compare commits

...
Author SHA1 Message Date
Martin Brennan 9369d9d0d7 WIP: Adding Enum button back to AI tools parameters form 2025-03-18 16:41:41 +10:00
Natalie Tay 5bf61ef9e1 DEV: Support multiple tests per eval and followups per test (#1199)
See https://github.com/discourse/discourse-ai-evals/pull/9 for format of prompts
2025-03-18 11:42:05 +08:00
Keegan George 107f14456b DEV: Convert tool editor to form kit (#1135)
* DEV: Make tool presets a dropdown

* DEV: Select tool presets via DMenu instead

* WIP

* WIP: Add parameter types, uploader, script, etc.

* WIP

* updates

* fix lint

* FIX: spec

* fixes
2025-03-17 11:38:25 -03:00
Sam 269f169abf DEV: add API to get topic info from a custom tool (#1197)
Previously we could only get post info
2025-03-17 18:47:23 +11:00
Jarek Radosz 7d7c169afa DEV: Update linting (#1194) 2025-03-17 15:14:53 +11:00
SamandNatalie Tay 9211b211f5 FEATURE: silent triage using ai persona (#1193)
This allows for a new mode in persona triage where nothing is posted on topics.

This allows people to perform all triage actions using tools

Additionally introduces new APIs to create chat messages from tools which can be useful in certain moderation scenarios

Co-authored-by: Natalie Tay <[email protected]>

* remove TODO code

---------

Co-authored-by: Natalie Tay <[email protected]>
2025-03-17 15:14:09 +11:00
Kris 24e6aa52bb UX: try AI search to side on large screens (#1196) 2025-03-14 14:11:02 -04:00
Keegan George 48fff48aa2 FIX: failing image caption spec (#1195) 2025-03-14 10:19:04 -07:00
Kris bd40076dc1 FIX: need to create model when testing new embedding (#1192) 2025-03-14 13:11:44 -04:00
Natalie Tay 3533cd1acc DEV: Allow prompt-type evals to take in several prompts and messages (#1190)
* DEV: Allow prompt-type evals to take in several prompts and messages

* ❄️
2025-03-14 15:46:22 +11:00
Kris 51ca942d7d REFACTOR: update embeddings to formkit (#1188) 2025-03-13 11:27:38 -04:00
Jarek Radosz ec8018333e DEV: Update linting (#1191) 2025-03-13 13:25:38 +00:00
Keegan George 6aaf8a0619 DEV: Use existing topic embeddings when suggesting tags/categories on edit (#1189)
When editing a topic (instead of creating one) and using the
tag/category suggestion buttons. We want to use existing topic
embeddings instead of creating new ones.
2025-03-12 18:52:07 -07:00
Penar Musaraj ac29d3080f DEV: Fix SCSS linting issue (#1187) 2025-03-12 13:25:17 -04:00
Sam 65503a5038 FIX: various issues with llm and triage management (#1186)
- Fix search API to only include column_names when present to make the API less confusing
- Ensure correct LLM is used in PMs by tracking and preferring the last bot user
- Fix persona_id conversion from string to integer in custom fields
- Add missing test for PM triage with no replies - ensure we don't try to auto title topic 
- Ensure bot users are properly added to PMs
- Make title setting optional when replying to posts
- Add ability to control stream_reply behavior

These changes improve reliability and fix edge cases in bot interactions,
particularly in private messages with multiple LLMs and while triaging posts using personas
2025-03-12 18:32:02 +11:00
Keegan George b17c688162 DEV: Improve title suggester suggestions when editing topic (#1182)
This update ensures topic title suggestions when suggesting from edit topic take into account the whole topic for more accurate title suggestions.
2025-03-11 11:16:06 -07:00
Discourse Translator Bot e59184b65c Update translations (#1185) 2025-03-11 17:47:51 +01:00
Sam 8f4cd2fcbd FEATURE: allow disabling of top_p and temp for thinking models (#1184)
thinking models such as Claude 3.7 Thinking and o1 / o3 do not
support top_p or temp.

Previously you would have to carefully remove it from everywhere
by having it be a provider param we now support blanker removing
without forcing people to update automation rules or personas
2025-03-11 16:54:02 +11:00
Guhyoun Nam f4708d4178 DEV: Ai summary utilizing name instead of username (#1180)
* DEV: Ai summary utilizing name instead of username

* utilize username if name not available

* test added

* test added 2
2025-03-11 14:29:13 +11:00
dependabot[bot] 833f914e34 Build(deps-dev): Bump rack from 3.1.10 to 3.1.12 (#1181)
Bumps [rack](https://github.com/rack/rack) from 3.1.10 to 3.1.12.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v3.1.10...v3.1.12)

---
updated-dependencies:
- dependency-name: rack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 14:27:34 +11:00
Sam 168d9d8eb9 FIX: AI triage support and refactor search functionality (#1175)
* FIX: do not add bot user to PM when using responders

* Allow AI tool to call search directly

* remove stray p
2025-03-11 14:26:07 +11:00
Krzysztof Kotlarek 3da4f5eac3 DEV: fix Zeitwerk issue with SpamScanner class (#1183)
When spam scanner is enabled and code is reloaded, developer experience this error:

```
NameError at /posts
===================

uninitialized constant DiscourseAi::AiModeration::EntryPoint::SpamScanner

> To access an interactive console with this error, point your browser to: /__better_errors

plugins/discourse-ai/lib/ai_moderation/entry_point.rb, line 7
```

It is because when we call `SpamScanner` it is searched within parent `DiscourseAi::AiModeration::EntryPoint` namespace.

We can help a bit Zeitwerk by calling SpamScanner more explicitly.
2025-03-11 13:24:27 +11:00
Keegan George bb32d0d737 FEATURE: Add ability to disable search discoveries (#1177)
This update adds the ability to disable search discoveries. This can be done through a tooltip when search discoveries are shown. It can also be done in the AI user preferences, which has also been updated to accommodate more than just the one image caption setting.
2025-03-10 14:17:58 -07:00
Joffrey JAFFEUXandKeegan George 339251a371 FIX: prevents double escaping of filters (#1179)
---------

Co-authored-by: Keegan George <[email protected]>
2025-03-10 14:17:24 -07:00
Rafael dos Santos Silva 511b10285d DEV: rake task to populate sentiment in dev env (#1176) 2025-03-10 14:21:00 -03:00
Keegan George 94e0834b88 DEV: Sentiment follow-up updates (#1172)
### ✨ What's new?
- [X] show sentiment counts by color on doughnut without needing to hover
- [X] minor UI padding adjustments
- [X] hide filters that are not allowed to be adjusted in selected chart view
- [X] fix z-index issues with drill-down filters
2025-03-10 09:25:55 -07:00
Roman Rizzi 1ce25c5a8b DEV: Don't classify post sentiments on creation. (#1174)
We'll rely on the backfill instead, which runs every five minutes. Bump default batch size x10 to avoid lagging.
2025-03-07 14:45:10 -03:00
David Taylor 1b570fcd01 PERF: Move sentiment analysis to "low" sidekiq queue (#1173) 2025-03-07 14:12:15 +00:00
Natalie Tay 5b63e597d0 DEV: A rake task to generate topics (#1171)
Adds a new rake task to generate topics using the AI helper model.

Generate topics using 
```
bundle exec rake "ai:generate_topics[Don Quijote de la Mancha\, Cien años de soledad\, Os Lusíadas\,三国演义]"
```

For now it randomly chooses a non-read-restricted category and a random bunch of users.
2025-03-06 22:25:50 +08:00
Sam 01893bb6ed FEATURE: Add persona-based replies and whisper support to LLM triage (#1170)
This PR enhances the LLM triage automation with several important improvements:

- Add ability to use AI personas for automated replies instead of canned replies
- Add support for whisper responses
- Refactor LLM persona reply functionality into a reusable method
- Add new settings to configure response behavior in automations
- Improve error handling and logging
- Fix handling of personal messages in the triage flow
- Add comprehensive test coverage for new features
- Make personas configurable with more flexible requirements

This allows for more dynamic and context-aware responses in automated workflows, with better control over visibility and attribution.
2025-03-06 17:18:15 +11:00
Sam 453bb4821f DEV: fix build (#1168) 2025-03-06 11:19:14 +11:00
Sam e255c7a8f0 FEATURE: automation triage using personas (#1126)
## LLM Persona Triage
- Allows automated responses to posts using AI personas
- Configurable to respond as regular posts or whispers
- Adds context-aware formatting for topics and private messages
- Provides special handling for topic metadata (title, category, tags)

## LLM Tool Triage
- Enables custom AI tools to process and respond to posts
- Tools can analyze post content and invoke personas when needed
- Zero-parameter tools can be used for automated workflows
- Not enabled in production yet

## Implementation Details
- Added new scriptable registration in discourse_automation/ directory
- Created core implementation in lib/automation/ modules
- Enhanced PromptMessagesBuilder with topic-style formatting
- Added helper methods for persona and tool selection in UI
- Extended AI Bot functionality to support whisper responses
- Added rate limiting to prevent abuse

## Other Changes
- Added comprehensive test coverage for both automation types
- Enhanced tool runner with LLM integration capabilities
- Improved error handling and logging

This feature allows forum admins to configure AI personas to automatically respond to posts based on custom criteria and leverage AI tools for more complex triage workflows.

Tool Triage has been disabled in production while we finalize details of new scripting capabilities.
2025-03-06 09:41:09 +11:00
Keegan George 8863cf0c86 DEV: Updates to sentiment analysis reports (#1161)
**This PR includes a variety of updates to the Sentiment Analysis report:**
- [X] Conditionally showing sentiment reports based on `sentiment_enabled` setting
- [X] Sentiment reports should only be visible in sidebar if data is in the reports
- [X] Fix infinite loading of posts in drill down
- [x] Fix markdown emojis showing not showing as emoji representation
- [x] Drill down of posts should have URL
- [x] ~~Different doughnut sizing based on post count~~ [reverting and will address in follow-up (see: `/t/146786/47`)]
- [X] Hide non-functional export button 
- [X] Sticky drill down filter nav
2025-03-05 13:53:56 -08:00
Kelv b49d454ad9 DEV: update display name field in AiLlmEditorForm to show more relevant tooltip (#1166) 2025-03-05 16:49:19 +08:00
Ted Johansson 584f5f2b6e DEV: Use full URL for problem check message (#1165)
Better to construct the URL in Ruby and pass it to I18n, so we don't have to mess with the translations if the URL changes.
2025-03-05 11:31:23 +08:00
Discourse Translator Bot fff0bc0f8c Update translations (#1164) 2025-03-04 16:12:06 +01:00
Sam 28af4434c5 Dev: eval improvements (#1162)
Adds sonnet 3.7
Adds support for temp in eval framework
2025-03-04 16:12:25 +11:00
Sam f6eedf3e0b FEATURE: implement thinking token support (#1155)
adds support for "thinking tokens" - a feature that exposes the model's reasoning process before providing the final response. Key improvements include:

- Add a new Thinking class to handle thinking content from LLMs
- Modify endpoints (Claude, AWS Bedrock) to handle thinking output
- Update AI bot to display thinking in collapsible details section
- Fix SEARCH/REPLACE blocks to support empty replacement strings and general improvements to artifact editing
- Allow configurable temperature in triage and report automations
- Various bug fixes and improvements to diff parsing
2025-03-04 12:22:30 +11:00
Martin Brennan 3f20b24aa3 FIX: Use route for admin sentiment sidebar links (#1160)
Minor, but there is a core bug when using `href` at the moment,
this fixes the issue, and I will do more in core separately.
2025-03-03 15:37:45 +10:00
Kris 5368b0c7d0 UX: add links to sentiment to admin sidebar (#1158) 2025-02-28 15:29:04 -05:00
Rafael dos Santos Silva eccfbad046 UX: Update Sambanova LLM templates (#1157) 2025-02-28 15:00:15 -03:00
Keegan George a3fb5cdc21 FIX: Subsequent results in full page search should re-trigger discovery (#1156)
This update fixes an issue where subsequent results in full page search were not trigger Discobot discoveries to search for the new result.
2025-02-27 10:18:42 -08:00
Keegan George e15952031d UX: Smoother streaming for discoveries (#1154)
## 🔍 Overview
This update ensures that the streaming for discoveries is smoother, especially on first update.


## ➕ More details
To help with smoother streaming, the discovery preview (which was being tracked as a separate property in the JS logic) will be removed and the entire discovery content will be shown/hidden via the existing CSS. The preview was already receiving the full update even though it was visually hidden, so removing the separate property shouldn't have any negative performance hit. Visually hiding it with CSS only will help simplify the component and also allow for smoother streaming. We will instead remove the buffered streaming approach and instead use typing timers similar to what we did for streaming summarization. 

No related tests as streaming animations are difficult to test.
2025-02-27 07:32:39 -08:00
Martin Brennan aa13d16022 UX: Add description to configuration nav (#1153)
This will be used in the new admin search UI
2025-02-26 10:54:45 +10:00
Keegan George 9aafca03f5 UX: Improvements to sentiment analysis report (#1150)
This update improves some of the UI around sentiment analysis reports:

1. Improve titles so it is above and truncated when long
2. Change doughnut to only show total count
3. Ensures sentiment posts have dates
4. Ensure expand post doesn't appear on short posts
2025-02-25 07:47:30 -08:00
Discourse Translator Bot 0018475987 Update translations (#1152) 2025-02-25 15:11:37 +01:00
Sam fe19133dd4 FEATURE: full support for Sonnet 3.7 (#1151)
* FEATURE: full support for Sonnet 3.7

- Adds support for Sonnet 3.7 with reasoning on bedrock and anthropic
- Fixes regression where provider params were not populated

Note. reasoning tokens are hardcoded to minimum of 100 maximum of 65536

* FIX: open ai non reasoning models need to use deprecate max_tokens
2025-02-25 17:32:12 +11:00
Sam 84e791a941 FIX: legacy reasoning models not working, missing provider params (#1149)
* FIX: legacy reasoning models not working, missing provider params

1. Legacy reasoning models (o1-preview / o1-mini) do not support developer or system messages, do not use them.
2. LLM editor form not showing all provider params due to missing remap

* add system test
2025-02-24 16:38:23 +11:00
Natalie Tay 2486e0e2dd DEV: Extract configs to a yml file and allow local config (#1142) 2025-02-24 16:22:19 +11:00
Keegan George 08377bab35 DEV: Sentiment analysis report follow-up updates (#1145)
* DEV: make include subcategories checkbox operational

* DEV: add pagination for post requests

* WIP: selected chart UX improvements

* DEV: Functional sentiment filters

* DEV: Reset filters after going back

* DEV: Add category colors, improve UX

* DEV: Update spec
2025-02-24 16:21:10 +11:00
Discourse Translator Bot 43cbb7f45f Update translations (#1148) 2025-02-24 16:20:25 +11:00
Jarek Radosz 4f94675a72 DEV: Update license (#1147) 2025-02-24 11:20:06 +08:00
Kris 55dde0a9e6 UX: minor adjustments to search bot (#1146) 2025-02-21 19:40:53 -05:00
Roman Rizzi 09f4895302 UI: Custom icon for Discobot discoveries (#1144) 2025-02-21 12:22:36 -03:00
Rafael dos Santos Silva 04678cab04 FIX: Discovery search would break normal search for anons (#1143) 2025-02-21 12:14:47 -03:00
Roman Rizzi f922012499 UX: Display a tooltip signalling this is an AI powered feature (#1141) 2025-02-20 16:21:26 -03:00
Roman RizziandKeegan George 6765a13a40 FEATURE: Experimental search results from an AI Persona. (#1139)
* FEATURE: Experimental search results from an AI Persona.

When a user searches discourse, we'll send the query to an AI Persona to provide additional context and enrich the results. The feature depends on the user being a member of a group to which the persona has access.

* Update assets/stylesheets/common/ai-blinking-animation.scss

Co-authored-by: Keegan George <[email protected]>

---------

Co-authored-by: Keegan George <[email protected]>
2025-02-20 14:37:58 -03:00
Keegan George 24f0e1262d FEATURE: New sentiment analysis visualization report (#1109)
## 🔍 Overview
This update adds a new report page at `admin/reports/sentiment_analysis` where admins can see a sentiment analysis report for the forum grouped by either category or tags. 

## ➕ More details
The report can breakdown either category or tags into positive/negative/neutral sentiments based on the grouping (category/tag). Clicking on the doughnut visualization will bring up a post list of all the posts that were involved in that classification with further sentiment classifications by post. 

The report can additionally be sorted in alphabetical order or by size, as well as be filtered by either category/tag based on the grouping.

## 👨🏽‍💻 Technical Details
The new admin report is registered via the pluginAPi with `api.registerReportModeComponent` to register the custom sentiment doughnut report. However, when each doughnut visualization is clicked, a new endpoint found at: `/discourse-ai/sentiment/posts` is fetched to showcase posts classified by sentiments based on the respective params.


## 📸 Screenshots
![Screenshot 2025-02-14 at 11 11 35](https://github.com/user-attachments/assets/a63b5ab8-4fb2-477d-bd29-92545f44ff09)
2025-02-20 09:14:10 -08:00
Keegan George 1f9f330ce2 DEV: Add summarization type to eval (#1138)
Adds `type: summarization` for topic summarization eval: https://github.com/discourse/discourse-ai-evals/pull/4
2025-02-20 09:07:23 -08:00
Roman Rizzi 70248ccfca DEV: Update annotations for models using Core tables (#1140) 2025-02-20 11:49:50 -03:00
Keegan George af47873f28 FIX: hardcoded require for evals (#1137)
The require for `discourse/config/environment` should point to the local user's core Discourse environment instead of the hardcoded path for Sam's env.
2025-02-19 11:56:52 -08:00
Rafael dos Santos Silva 37bf160d26 FIX: Add workaround to pgvector HNSW search limitations (#1133)
From [pgvector/pgvector](https://github.com/pgvector/pgvector) README

> With approximate indexes, filtering is applied after the index is scanned. If a condition matches 10% of rows, with HNSW and the default hnsw.ef_search of 40, only 4 rows will match on average. For more rows, increase hnsw.ef_search.
> 
> Starting with 0.8.0, you can enable [iterative index scans](https://github.com/pgvector/pgvector#iterative-index-scans), which will automatically scan more of the index when needed.


Since we are stuck on 0.7.0 we are going the first option for now.
2025-02-19 16:30:01 -03:00
Kris 3a755ca883 DEV: add summary button wrapper removed from core (#1136) 2025-02-19 12:58:10 -05:00
Sam 12f00a62d2 FIX: use max_completion_tokens for open ai models (#1134)
max_tokens is now deprecated per API
2025-02-19 15:49:15 +11:00
Sam 0c9466059c DEV: improve artifact editing and eval system (#1130)
- Add non-contiguous search/replace support using ... syntax
- Add judge support for evaluating LLM outputs with ratings
- Improve error handling and reporting in eval runner
- Add full section replacement support without search blocks
- Add fabricators and specs for artifact diffing
- Track failed searches to improve debugging
- Add JS syntax validation for artifact versions in eval system
- Update prompt documentation with clear guidelines

* improve eval output

* move error handling

* llm as a judge

* fix spec

* small note on evals
2025-02-19 15:44:33 +11:00
Keegan George 02f0908963 DEV: Misconfigured llm should go to edit page (#1132) 2025-02-18 10:34:18 -08:00
261 changed files with 10445 additions and 3240 deletions
+1
View File
@@ -4,3 +4,4 @@ node_modules
.env
evals/log
evals/cases
config/eval-llms.local.yml
+59 -45
View File
@@ -1,79 +1,93 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (7.1.3.4)
activesupport (8.0.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.8)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
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.5)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
json (2.7.2)
language_server-protocol (3.17.0.3)
minitest (5.24.1)
mutex_m (0.2.0)
parallel (1.25.1)
parser (3.3.3.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.0)
rack (3.1.10)
racc (1.8.1)
rack (3.1.12)
rainbow (3.1.1)
regexp_parser (2.9.2)
rexml (3.3.9)
rubocop (1.64.1)
regexp_parser (2.10.0)
rubocop (1.74.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
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 (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.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, < 3.0)
rubocop-ast (1.31.3)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.1)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
rubocop-discourse (3.8.1)
rubocop-capybara (2.22.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-discourse (3.12.1)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rails (>= 2.25.0)
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.30.0)
rubocop-factory_bot (2.26.1)
rubocop (~> 1.61)
rubocop-rails (2.25.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.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.0.2)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
rubocop-rspec (~> 3, >= 3.0.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 (2.5.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
@@ -83,4 +97,4 @@ DEPENDENCIES
syntax_tree
BUNDLED WITH
2.5.10
2.6.6
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2021 Civilized Discourse Construction Kit
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
+24
View File
@@ -3,3 +3,27 @@
**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
@@ -1,6 +1,10 @@
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");
}
@@ -8,10 +12,10 @@ export default class DiscourseAiToolsNewRoute extends DiscourseRoute {
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);
}
}
@@ -5,5 +5,6 @@
@presets={{this.presets}}
@llms={{this.llms}}
@settings={{this.settings}}
@selectedPreset={{this.selectedPreset}}
/>
</section>
@@ -44,6 +44,31 @@ module DiscourseAi
render json: { bot_username: bot_user.username_lower }, status: 200
end
def discover
ai_persona =
AiPersona.all_personas.find do |persona|
persona.id == SiteSetting.ai_bot_discover_persona.to_i
end
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
end
end
end
@@ -53,7 +53,12 @@ module DiscourseAi
end
def suggest_title
input = get_text_param!
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
prompt = CompletionPrompt.enabled_by_name("generate_titles")
raise Discourse::InvalidParameters.new(:mode) if !prompt
@@ -73,22 +78,26 @@ module DiscourseAi
end
def suggest_category
input = get_text_param!
input_hash = { text: input }
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(
input_hash,
current_user,
).categories,
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(current_user, opts).categories,
status: 200
end
def suggest_tags
input = get_text_param!
input_hash = { text: input }
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(input_hash, current_user).tags,
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(current_user, opts).tags,
status: 200
end
@@ -0,0 +1,101 @@
# 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,
c.name AS category_name,
c.color AS category_color,
c.slug AS category_slug,
c.description AS category_description,
(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
@@ -2,6 +2,8 @@
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?
+53
View File
@@ -0,0 +1,53 @@
# 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.find do |persona|
persona.id == SiteSetting.ai_bot_discover_persona.to_i
end
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::AiBot::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 }
bot.reply(
{ conversation_context: [{ type: :user, content: query }], skip_tool_details: true },
) 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
+13
View File
@@ -4,6 +4,19 @@ class AiArtifactVersion < ActiveRecord::Base
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
+4
View File
@@ -2,6 +2,10 @@
class ClassificationResult < ActiveRecord::Base
belongs_to :target, polymorphic: true
def self.has_sentiment_classification?
where(classification_type: "sentiment").exists?
end
end
# == Schema Information
+12
View File
@@ -26,13 +26,23 @@ class LlmModel < ActiveRecord::Base
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,
reasoning_effort: {
type: :enum,
@@ -65,6 +75,8 @@ class LlmModel < ActiveRecord::Base
provider_order: :text,
provider_quantizations: :text,
disable_streaming: :checkbox,
disable_temperature: :checkbox,
disable_top_p: :checkbox,
},
}
end
+1
View File
@@ -173,6 +173,7 @@ end
# force_review :boolean default(FALSE), not null
# reject_reason :text
# potentially_illegal :boolean default(FALSE)
# type_source :string default("unknown"), not null
#
# Indexes
#
+1
View File
@@ -230,6 +230,7 @@ end
# force_review :boolean default(FALSE), not null
# reject_reason :text
# potentially_illegal :boolean default(FALSE)
# type_source :string default("unknown"), not null
#
# Indexes
#
@@ -0,0 +1,38 @@
# frozen_string_literal: true
class AiSentimentPostSerializer < ApplicationSerializer
attributes :post_id,
:topic_id,
:topic_title,
:post_number,
:username,
:name,
:avatar_template,
:excerpt,
:sentiment,
:truncated,
:category,
: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
def category
{
id: object.category_id,
name: object.category_name,
color: object.category_color,
slug: object.category_slug,
description: object.category_description,
}
end
end
+9 -12
View File
@@ -8,10 +8,6 @@ class ProblemCheck::AiLlmStatus < ProblemCheck
llm_errors
end
def base_path
Discourse.base_path
end
private
def llm_errors
@@ -26,20 +22,21 @@ class ProblemCheck::AiLlmStatus < ProblemCheck
blk.call
nil
rescue => e
error_message = parse_error_message(e.message)
message =
"#{I18n.t("dashboard.problem.ai_llm_status", { base_path: base_path, model_name: model.display_name, model_id: model.id })}"
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: {
model_id: model.id,
model_name: model.display_name,
error: error_message,
},
details:,
)
end
end
@@ -32,8 +32,10 @@ export default class AiTool extends RestModel {
if (parameter.enum && parameter.enum.length) {
parameter.enum = new TrackedArray(parameter.enum);
parameter.isEnum = true;
} else {
parameter.enum = null;
parameter.isEnum = false;
}
return parameter;
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@ import getURL from "discourse/lib/get-url";
export default class AiArtifactComponent extends Component {
@service siteSettings;
@tracked expanded = false;
@tracked showingArtifact = false;
@@ -0,0 +1,115 @@
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);
}
}
<template>
<ul class="ai-blinking-animation" {{didInsert this.setupAnimation}}>
{{#each this.blocks as |block|}}
<li
class={{concatClass
"ai-blinking-animation__list-item"
(if block.show "show")
(if block.shown "is-shown")
(if block.blinking "blink")
}}
{{didUpdate (fn this.onBlinking block) block.blinking}}
{{didUpdate (fn this.onShowing block) block.show}}
{{willDestroy this.teardownAnimation}}
></li>
{{/each}}
</ul>
</template>
}
@@ -15,6 +15,7 @@ export default class AiComposerHelperMenu extends Component {
@service siteSettings;
@service currentUser;
@service site;
@tracked newSelectedText;
@tracked diff;
@tracked customPromptValue = "";
File diff suppressed because it is too large Load Diff
@@ -61,7 +61,10 @@ export default class AiLlmEditorForm extends Component {
provider: model.provider,
enabled_chat_bot: model.enabled_chat_bot,
vision_enabled: model.vision_enabled,
provider_params: model.provider_params,
provider_params: this.computeProviderParams(
model.provider,
model.provider_params
),
llm_quotas: model.llm_quotas,
};
}
@@ -128,12 +131,12 @@ export default class AiLlmEditorForm extends Component {
return !this.args.model.isNew;
}
computeProviderParams(provider) {
computeProviderParams(provider, currentParams = {}) {
const params = this.args.llms.resultSetMeta.provider_params[provider] ?? {};
return Object.fromEntries(
Object.entries(params).map(([k, v]) => [
k,
v?.type === "enum" ? v.default : null,
currentParams[k] ?? (v?.type === "enum" ? v.default : null),
])
);
}
@@ -272,7 +275,7 @@ export default class AiLlmEditorForm extends Component {
@validation="required|length:1,100"
@disabled={{this.seeded}}
@format="large"
@tooltip={{i18n "discourse_ai.llms.hints.max_prompt_tokens"}}
@tooltip={{i18n "discourse_ai.llms.hints.display_name"}}
as |field|
>
<field.Input />
@@ -0,0 +1,49 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
export default class AiSearchDiscoveriesTooltip extends Component {
@service discobotDiscoveries;
<template>
<span class="ai-search-discoveries-tooltip">
<DTooltip @placement="top-end" @interactive={{true}}>
<:trigger>
{{icon "circle-info"}}
</:trigger>
<:content>
<div class="ai-search-discoveries-tooltip__content">
<div class="ai-search-discoveries-tooltip__header">
{{i18n "discourse_ai.discobot_discoveries.tooltip.header"}}
</div>
<div class="ai-search-discoveries-tooltip__description">
{{#if this.discobotDiscoveries.modelUsed}}
{{i18n
"discourse_ai.discobot_discoveries.tooltip.content"
model=this.discobotDiscoveries.modelUsed
}}
{{/if}}
</div>
<div class="ai-search-discoveries-tooltip__actions">
<DButton
class="btn-transparent btn-primary"
@label="discourse_ai.discobot_discoveries.tooltip.actions.info"
@href="https://meta.discourse.org/t/conversational-ai-search-coming-to-discourse-ai/355939"
/>
<DButton
class="btn-transparent btn-danger"
@label="discourse_ai.discobot_discoveries.tooltip.actions.disable"
@action={{this.discobotDiscoveries.disableDiscoveries}}
/>
</div>
</div>
</:content>
</DTooltip>
</span>
</template>
}
@@ -0,0 +1,249 @@
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 didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel, later } from "@ember/runloop";
import { service } from "@ember/service";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import AiBlinkingAnimation from "./ai-blinking-animation";
const DISCOVERY_TIMEOUT_MS = 10000;
const STREAMED_TEXT_SPEED = 23;
export default class AiSearchDiscoveries extends Component {
@service search;
@service messageBus;
@service discobotDiscoveries;
@service appEvents;
@tracked loadingDiscoveries = false;
@tracked hideDiscoveries = false;
@tracked fullDiscoveryToggled = false;
@tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150;
@tracked isStreaming = false;
@tracked streamedText = "";
discoveryTimeout = null;
typingTimer = null;
streamedTextLength = 0;
constructor() {
super(...arguments);
this.appEvents.on(
"full-page-search:trigger-search",
this,
this.triggerDiscovery
);
}
willDestroy() {
super.willDestroy(...arguments);
this.appEvents.off(
"full-page-search:trigger-search",
this,
this.triggerDiscovery
);
}
typeCharacter() {
if (this.streamedTextLength < this.discobotDiscoveries.discovery.length) {
this.streamedText += this.discobotDiscoveries.discovery.charAt(
this.streamedTextLength
);
this.streamedTextLength++;
this.typingTimer = later(this, this.typeCharacter, STREAMED_TEXT_SPEED);
} else {
this.typingTimer = null;
}
}
onTextUpdate() {
this.cancelTypingTimer();
this.typeCharacter();
}
cancelTypingTimer() {
if (this.typingTimer) {
cancel(this.typingTimer);
}
}
resetStreaming() {
this.cancelTypingTimer();
this.streamedText = "";
this.streamedTextLength = 0;
}
@bind
async _updateDiscovery(update) {
if (this.query === update.query) {
if (this.discoveryTimeout) {
cancel(this.discoveryTimeout);
}
if (!this.discobotDiscoveries.discovery) {
this.discobotDiscoveries.discovery = "";
}
const newText = update.ai_discover_reply;
this.discobotDiscoveries.modelUsed = update.model_used;
this.loadingDiscoveries = false;
// Handling short replies.
if (update.done) {
this.discobotDiscoveries.discovery = newText;
this.streamedText = newText;
this.isStreaming = false;
// Clear pending animations
this.cancelTypingTimer();
} else if (newText.length > this.discobotDiscoveries.discovery.length) {
this.discobotDiscoveries.discovery = newText;
this.isStreaming = true;
await this.onTextUpdate();
}
}
}
@bind
unsubscribe() {
this.messageBus.unsubscribe(
"/discourse-ai/ai-bot/discover",
this._updateDiscovery
);
}
@bind
subscribe() {
this.messageBus.subscribe(
"/discourse-ai/ai-bot/discover",
this._updateDiscovery
);
}
get query() {
return this.args?.searchTerm || this.search.activeGlobalSearchTerm;
}
get toggleLabel() {
if (this.fullDiscoveryToggled) {
return "discourse_ai.discobot_discoveries.collapse";
} else {
return "discourse_ai.discobot_discoveries.tell_me_more";
}
}
get toggleIcon() {
if (this.fullDiscoveryToggled) {
return "chevron-up";
} else {
return "";
}
}
get canShowExpandtoggle() {
return (
!this.loadingDiscoveries &&
this.renderedDiscovery.length > this.discoveryPreviewLength
);
}
get renderedDiscovery() {
return this.isStreaming
? this.streamedText
: this.discobotDiscoveries.discovery;
}
get renderPreviewOnly() {
return !this.fullDiscoveryToggled && this.canShowExpandtoggle;
}
@action
async triggerDiscovery() {
if (this.discobotDiscoveries.lastQuery === this.query) {
this.hideDiscoveries = false;
return;
} else {
this.resetStreaming();
this.discobotDiscoveries.resetDiscovery();
}
this.hideDiscoveries = false;
this.loadingDiscoveries = true;
this.discoveryTimeout = later(
this,
this.timeoutDiscovery,
DISCOVERY_TIMEOUT_MS
);
try {
this.discobotDiscoveries.lastQuery = this.query;
await ajax("/discourse-ai/ai-bot/discover", {
data: { query: this.query },
});
} catch {
this.hideDiscoveries = true;
}
}
@action
toggleDiscovery() {
this.fullDiscoveryToggled = !this.fullDiscoveryToggled;
}
timeoutDiscovery() {
this.loadingDiscoveries = false;
this.discobotDiscoveries.discovery = "";
this.discobotDiscoveries.discoveryTimedOut = true;
}
<template>
<div
class="ai-search-discoveries"
{{didInsert this.subscribe @searchTerm}}
{{didUpdate this.subscribe @searchTerm}}
{{didInsert this.triggerDiscovery this.query}}
{{willDestroy this.unsubscribe}}
>
<div class="ai-search-discoveries__completion">
{{#if this.loadingDiscoveries}}
<AiBlinkingAnimation />
{{else if this.discobotDiscoveries.discoveryTimedOut}}
{{i18n "discourse_ai.discobot_discoveries.timed_out"}}
{{else}}
<article
class={{concatClass
"ai-search-discoveries__discovery"
(if this.renderPreviewOnly "preview")
(if this.isStreaming "streaming")
"streamable-content"
}}
>
<div class="cooked">
<CookText @rawText={{this.renderedDiscovery}} />
</div>
</article>
{{#if this.canShowExpandtoggle}}
<DButton
class="btn-flat btn-text ai-search-discoveries__toggle"
@label={{this.toggleLabel}}
@icon={{this.toggleIcon}}
@action={{this.toggleDiscovery}}
/>
{{/if}}
{{/if}}
</div>
</div>
</template>
}
@@ -1,126 +1,18 @@
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";
import { i18n } from "discourse-i18n";
import AiBlinkingAnimation from "./ai-blinking-animation";
import AiIndicatorWave from "./ai-indicator-wave";
class Block {
@tracked show = false;
@tracked shown = false;
@tracked blinking = false;
const AiSummarySkeleton = <template>
<div class="ai-summary__container">
<AiBlinkingAnimation />
constructor(args = {}) {
this.show = args.show ?? false;
this.shown = args.shown ?? false;
}
}
<span>
<div class="ai-summary__generating-text">
{{i18n "summary.in_progress"}}
</div>
<AiIndicatorWave @loading={{true}} />
</span>
</div>
</template>;
const BLOCKS_SIZE = 20; // changing this requires to change css accordingly
export default class AiSummarySkeleton 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);
}
}
<template>
<div class="ai-summary__container">
<ul class="ai-summary__list" {{didInsert this.setupAnimation}}>
{{#each this.blocks as |block|}}
<li
class={{concatClass
"ai-summary__list-item"
(if block.show "show")
(if block.shown "is-shown")
(if block.blinking "blink")
}}
{{didUpdate (fn this.onBlinking block) block.blinking}}
{{didUpdate (fn this.onShowing block) block.show}}
{{willDestroy this.teardownAnimation}}
></li>
{{/each}}
</ul>
<span>
<div class="ai-summary__generating-text">
{{i18n "summary.in_progress"}}
</div>
<AiIndicatorWave @loading={{true}} />
</span>
</div>
</template>
}
export default AiSummarySkeleton;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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