Compare commits

...
Author SHA1 Message Date
David Taylor aee2081356 Fix lib directory 2024-05-02 19:19:09 +01:00
David Taylor e6bfec5bdb DEV: Update engine config to match skeleton
- adds `lib/` autoloading (and removes the plugin.rb workaround)
- adds eager loading of scheduled jobs in development (otherwise they don't work at all)
2024-05-02 18:53:22 +01:00
Martin Brennan b52d3c7d29 DEV: Moving around admin persona config routes again (#586)
The initial setup done in fb0d56324f
clashed with other plugins, I found this when trying to do the same
for Gamification. This uses a better routing setup and removes the
need to define the config nav link for Settings -- that is always inserted.

Relies on https://github.com/discourse/discourse/pull/26707
2024-05-02 12:42:30 +10:00
Sam 6623928b95 FIX: call after tool calls failing on OpenAI / Gemini (#599)
A recent change meant that llm instance got cached internally, repeat calls
to inference would cache data in Endpoint object leading model to
failures.

Both Gemini and Open AI expect a clean endpoint object cause they
set data.

This amends internals to make sure llm.generate will always operate
on clean objects
2024-05-01 17:50:58 +10:00
Discourse Translator Bot aab59b9327 Update translations (#598) 2024-04-30 21:57:37 +02:00
Kris 6860ee50be UX: prevent social share content from overflowing container (#597) 2024-04-30 09:47:21 -04:00
Sam 32b3004ce9 FEATURE: Add Question Consolidator for robust Upload support in Personas (#596)
This commit introduces a new feature for AI Personas called the "Question Consolidator LLM". The purpose of the Question Consolidator is to consolidate a user's latest question into a self-contained, context-rich question before querying the vector database for relevant fragments. This helps improve the quality and relevance of the retrieved fragments.

Previous to this change we used the last 10 interactions, this is not ideal cause the RAG would "lock on" to an answer. 

EG:

- User: how many cars are there in europe
- Model: detailed answer about cars in europe including the term car and vehicle many times
- User: Nice, what about trains are there in the US

In the above example "trains" and "US" becomes very low signal given there are pages and pages talking about cars and europe. This mean retrieval is sub optimal. 

Instead, we pass the history to the "question consolidator", it would simply consolidate the question to "How many trains are there in the United States", which would make it fare easier for the vector db to find relevant content. 

The llm used for question consolidator can often be less powerful than the model you are talking to, we recommend using lighter weight and fast models cause the task is very simple. This is configurable from the persona ui.

This PR also removes support for {uploads} placeholder, this is too complicated to get right and we want freedom to shift RAG implementation. 

Key changes:

1. Added a new `question_consolidator_llm` column to the `ai_personas` table to store the LLM model used for question consolidation.

2. Implemented the `QuestionConsolidator` module which handles the logic for consolidating the user's latest question. It extracts the relevant user and model messages from the conversation history, truncates them if needed to fit within the token limit, and generates a consolidated question prompt.

3. Updated the `Persona` class to use the Question Consolidator LLM (if configured) when crafting the RAG fragments prompt. It passes the conversation context to the consolidator to generate a self-contained question.

4. Added UI elements in the AI Persona editor to allow selecting the Question Consolidator LLM. Also made some UI tweaks to conditionally show/hide certain options based on persona configuration.

5. Wrote unit tests for the QuestionConsolidator module and updated existing persona tests to cover the new functionality.

This feature enables AI Personas to better understand the context and intent behind a user's question by consolidating the conversation history into a single, focused question. This can lead to more relevant and accurate responses from the AI assistant.
2024-04-30 13:49:21 +10:00
Sam 85734fef52 FIX: properly cache user locale (#593)
This blob is localized according to user locale, so we can end up
bleeding incorrect data in the cache
2024-04-26 09:28:35 -03:00
Roman Rizzi 283445cf81 FIX: RAG uploader must support multi-file indexing. (#592)
Updating the editing model's rag_uploads in the editor component broke multi-file uploading. Instead, we'll keep the uploads in the uploader and update the model when we finish.

This PR also fast-tracks the initial update so we can show feedback to the user quickly, and allows uploading MD files.

Bug reported on https://meta.discourse.org/t/discourse-ai-persona-upload-support/304049/11
2024-04-25 10:48:55 -03:00
Roman Rizzi 0c4069ab3f DEV: Remove non-LLM-based summarization strategies. (#589)
We removed these services from our hosting two weeks ago. It's safe to assume everyone has moved to other LLM-based options.
2024-04-23 12:11:04 -03:00
Discourse Translator Bot 66804bc13c Update translations (#587) 2024-04-23 16:22:37 +02:00
Sam 4d8b7742da FIX: many missing topics when categories excluded (#585)
We were forgetting about the NULL parent_category_id handling in
our check for sub categories
2024-04-23 08:53:51 +10:00
Rafael dos Santos Silva 595cde0fd6 FIX: Users with empty locales would error out during prompt localization (#584) 2024-04-22 13:55:10 -03:00
Sam 5ab86923ff FIX: when excluding categories also exclude children (#583)
This allows you to exclude trees of categories in a simple way

It also means you can no longer exclude "just the parent" but
this is a fair compromise.
2024-04-22 16:05:24 +10:00
Sam bd6f5caeac FEATURE: Stable diffusion 3 support (#582)
- Adds support for sd3 and sd3 turbo models - this requires new endpoints
- Adds a hack to normalize arrays in the tool calls
- Removes some leftover code
- Adds support for aspect ratio as well so you can generate wide or tall images
2024-04-19 18:08:16 +10:00
Sam a223d18f1a FIX: more robust function call support (#581)
For quite a few weeks now, some times, when running function calls
on Anthropic we would get a "stray" - "calls" line.

This has been enormously frustrating!

I have been unable to find the source of the bug so instead decoupled
the implementation and create a very clear "function call normalizer"

This new class is extensively tested and guards against the type of
edge cases we saw pre-normalizer.

This also simplifies the implementation of "endpoint" which no longer
needs to handle all this complex logic.
2024-04-19 06:54:54 +10:00
Sam 50be66ee63 FEATURE: Gemini 1.5 pro support and Claude Opus bedrock support (#580)
- Updated AI Bot to only support Gemini 1.5 (used to support 1.0) - 1.0 was removed cause it is not appropriate for Bot usage
- Summaries and automation can now lean on Gemini 1.5 pro
- Amazon added support for Claude 3 Opus, added internal support for it on bedrock
2024-04-17 15:37:19 +10:00
Sam a5e4ab2825 FIX: blank metadata leading to errors (#578)
blank metadata block in RAG was leading to an error, this handles the edge case
2024-04-17 13:46:40 +10:00
Discourse Translator Bot c2b2741f3d Update translations (#579) 2024-04-16 17:38:00 +02:00
Sam 4a29f8ed1c FEATURE: Enhance AI debugging capabilities and improve interface adjustments (#577)
* FIX: various RAG edge cases

- Nicer text to describe RAG, avoids the word RAG
- Do not attempt to save persona when removing uploads and it is not created
- Remove old code that avoided touching rag params on create

* FIX: Missing pause button for persona users

* Feature: allow specific users to debug ai request / response chains

This can help users easily tune RAG and figure out what is going
on with requests.

* discourse helper so it does not explode

* fix test

* simplify implementation
2024-04-15 23:22:06 +10:00
Bianca Nenciu 3e54697c5a FIX: Load categories for related topics (#570)
This is necessary when "lazy load categories" feature is enabled to
make sure the categories are rendered for all related topics.
2024-04-15 09:31:07 +10:00
Rafael dos Santos Silva 6090580e36 FEATURE: Add basic connection check to DNS SRV resources (#563) 2024-04-12 10:39:19 -03:00
Sam f6ac5cd0a8 FEATURE: allow tuning of RAG generation (#565)
* FEATURE: allow tuning of RAG generation

- change chunking to be token based vs char based (which is more accurate)
- allow control over overlap / tokens per chunk and conversation snippets inserted
- UI to control new settings

* improve ui a bit

* fix various reindex issues

* reduce concurrency

* try ultra low queue ... concurrency 1 is too slow.
2024-04-12 10:32:46 -03:00
Sam b906046aad FEATURE: Add Cohere models to AI helper and automation (#576) 2024-04-12 14:46:58 +10:00
Rafael dos Santos Silva 253e0b7b39 FEATURE: Mixtral/Mistral/Haiku Automation Support (#571)
Adds new models to automation, and makes LLM output parsing more robust.
2024-04-11 09:50:46 -03:00
Sam 23d12c8927 FEATURE: GPT-4 turbo vision support (#575)
Recent release of GPT-4 turbo adds vision support, this adds
the pipeline for sending images to Open AI.
2024-04-11 16:22:59 +10:00
Sam a77658e2b1 FIX: tools broke on Claude with no params (#574)
Some tools may have no params, allow that
2024-04-11 15:17:56 +10:00
Sam 3b0cfdbe5c FIX: throwing away first file in diff (#573)
We were chucking out the first file in a PR diff due to a
logic bug
2024-04-11 13:26:58 +10:00
Sam 0cbbf130b9 FIX: never mention the word JSON in tool preamble (#572)
Just having the word JSON can confuse models when we expect them
to deal solely in XML

Instead provide an example of how string arrays should be returned

Technically the tool framework supports int arrays and more, but
our current implementation only does string arrays.

Also tune the prompt construction not to give any tips about arrays
if none exist
2024-04-11 11:24:22 +10:00
Martin Brennan bab5e52e38 FIX: Secure/unsecure uploads when sharing AI conversations (#554)
This commit uses a new plugin modifier introduced in https://github.com/discourse/discourse/pull/26508
to mark all uploads as _not_ secure in shared PM AI conversations.
This is so images created by the AI bot (or uploaded by the user)
do not end up as broken URLs because of the security requirements
around them.

This relies on the UpdateTopicUploadSecurity job in core as well,
which is fired when an AI conversation is shared or deleted.
2024-04-11 10:00:41 +10:00
Sam 7f16d3ad43 FEATURE: Cohere Command R support (#558)
- Added Cohere Command models (Command, Command Light, Command R, Command R Plus) to the available model list
- Added a new site setting `ai_cohere_api_key` for configuring the Cohere API key
- Implemented a new `DiscourseAi::Completions::Endpoints::Cohere` class to handle interactions with the Cohere API, including:
   - Translating request parameters to the Cohere API format
   - Parsing Cohere API responses 
   - Supporting streaming and non-streaming completions
   - Supporting "tools" which allow the model to call back to discourse to lookup additional information
- Implemented a new `DiscourseAi::Completions::Dialects::Command` class to translate between the generic Discourse AI prompt format and the Cohere Command format
- Added specs covering the new Cohere endpoint and dialect classes
- Updated `DiscourseAi::AiBot::Bot.guess_model` to map the new Cohere model to the appropriate bot user

In summary, this PR adds support for using the Cohere Command family of models with the Discourse AI plugin. It handles configuring API keys, making requests to the Cohere API, and translating between Discourse's generic prompt format and Cohere's specific format. Thorough test coverage was added for the new functionality.
2024-04-11 07:24:17 +10:00
Rafael dos Santos Silva eb93b21769 FEATURE: Add BGE-M3 embeddings support (#569)
BAAI/bge-m3 is an interesting model, that is multilingual and with a
context size of 8192. Even with a 16x larger context, it's only 4x slower
to compute it's embeddings on the worst case scenario.

Also includes a minor refactor of the rake task, including setting model
and concurrency levels when running the backfill task.
2024-04-10 17:24:01 -03:00
Sam 6de9c53a71 FEATURE: remove gpt-4-turbo-0125 preview swap with gpt-4-turbo (#568)
Open AI just released gpt-4-turbo (with vision)

This change stops using the old preview model and swaps with the
officially released gpt-4-turbo

To come is an implementation of vision.
2024-04-10 09:53:20 -03:00
Kris f20ada00db UX: adjust AI share styles for images and quotes (#567) 2024-04-09 15:36:45 -04:00
Keegan George 8444789974 UX: Update suggestion radii to accommodate for new default (#564) 2024-04-09 10:48:18 -07:00
Discourse Translator Bot 310238d38a Update translations (#566) 2024-04-09 18:48:54 +02:00
Roman Rizzi aa8918911d UX: Display the indexing progress for RAG uploads (#557) 2024-04-09 11:03:07 -03:00
Keegan George 35fbf5c836 FIX: Ask AI highlight fixes (#562) 2024-04-08 11:00:03 -07:00
Bianca Nenciu 505650205d FIX: Fetch categories data using specific endpoint (#543)
It used to fetch it from /site.json, but /categories.json is the more
appropriate one. This one also implements pagination, so we have to do
one request per page.
2024-04-08 11:33:20 +03:00
Keegan George 55d6e1cdf8 UX: Update Ask AI highlight styling (#560) 2024-04-07 10:30:59 -07:00
Rafael dos Santos Silva 969fbae21e DEV: Hide quick search setting since it's experimental (#559) 2024-04-05 12:12:37 -03:00
Keegan George cb4d438506 UX: Highlight AI post helper selection (#520) 2024-04-04 11:35:01 -07:00
Keegan George fc6b937df7 FIX: Revert length issue (#556) 2024-04-04 10:28:17 -07:00
Sam 830cc26075 FEATURE: Add metadata support for RAG (#553)
* FEATURE: Add metadata support for RAG

You may include non indexed metadata in the RAG document by using

[[metadata ....]]

This information is attached to all the text below and provided to
the retriever.

This allows for RAG to operate within a rich amount of contexts
without getting lost

Also:

- re-implemented chunking algorithm so it streams
- moved indexing to background low priority queue

* Baran gem no longer required.

* tokenizers is on 4.4 ... upgrade it ...
2024-04-04 11:02:16 -03:00
Régis Hanol bc561eb332 DEV: replace diffhtml with morphlex (#555)
morphlex is a lighter and faster morphing library 🚀
2024-04-04 16:00:16 +02:00
Sam 6f5f34184b FEATURE: add Claude 3 Haiku bot support (#552)
it is close in performance to GPT 4 at a fraction of the cost,
nice to add it to the mix.

Also improves a test case to simulate streaming, I am hunting for
the "calls" word that is jumping into function calls and can't quite
find it.
2024-04-03 16:06:27 +11:00
Discourse Translator Bot 08b53e851c Update translations (#551) 2024-04-02 18:19:55 +02:00
Roman Rizzi 1f1c94e5c6 FEATURE: AI Bot RAG support. (#537)
This PR lets you associate uploads to an AI persona, which we'll split and generate embeddings from. When building the system prompt to get a bot reply, we'll do a similarity search followed by a re-ranking (if available). This will let us find the most relevant fragments from the body of knowledge you associated with the persona, resulting in better, more informed responses.

For now, we'll only allow plain-text files, but this will change in the future.

Commits:

* FEATURE: RAG embeddings for the AI Bot

This first commit introduces a UI where admins can upload text files, which we'll store, split into fragments,
and generate embeddings of. In a next commit, we'll use those to give the bot additional information during
conversations.

* Basic asymmetric similarity search to provide guidance in system prompt

* Fix tests and lint

* Apply reranker to fragments

* Uploads filter, css adjustments and file validations

* Add placeholder for rag fragments

* Update annotations
2024-04-01 13:43:34 -03:00
Kris a2018d4a04 UX: add general onebox styles (#550) 2024-03-29 09:50:28 -04:00
Sam fb81307c59 FEATURE: web browsing tool (#548)
This pull request makes several improvements and additions to the GitHub-related tools and personas in the `discourse-ai` repository:

1. It adds the `WebBrowser` tool to the  `Researcher` persona, allowing the AI to visit web pages, retrieve HTML content, extract the main content, and convert it to plain text.

2. It updates the `GithubFileContent`, `GithubPullRequestDiff`, and `GithubSearchCode` tools to handle HTTP responses more robustly (introducing size limits). 

3. It refactors the `send_http_request` method in the `Tool` class to follow redirects when specified, and to read the response body in chunks to avoid memory issues with large responses. (only for WebBrowser)

4. It updates the system prompt for the `Researcher` persona to provide more detailed guidance on when to use Google search vs web browsing, and how to optimize tool usage and reduce redundant requests.

5. It adds a new `web_browser_spec.rb` file with tests for the `WebBrowser` tool, covering various scenarios like handling different HTML structures and following redirects.
2024-03-28 16:01:58 +11:00
Sam 74c6725c75 FIX: avoid error when attempting to show ai helper to anon (#549)
Also adds an extra guard around ai helper context positioning.
2024-03-28 12:24:00 +11:00
SamandMartin Brennan 61e4c56e1a FEATURE: Add vision support to AI personas (Claude 3) (#546)
This commit adds the ability to enable vision for AI personas, allowing them to understand images that are posted in the conversation.

For personas with vision enabled, any images the user has posted will be resized to be within the configured max_pixels limit, base64 encoded and included in the prompt sent to the AI provider.

The persona editor allows enabling/disabling vision and has a dropdown to select the max supported image size (low, medium, high). Vision is disabled by default.

This initial vision support has been tested and implemented with Anthropic's claude-3 models which accept images in a special format as part of the prompt.

Other integrations will need to be updated to support images.

Several specs were added to test the new functionality at the persona, prompt building and API layers.

 - Gemini is omitted, pending API support for Gemini 1.5. Current Gemini bot is not performing well, adding images is unlikely to make it perform any better.

 - Open AI is omitted, vision support on GPT-4 it limited in that the API has no tool support when images are enabled so we would need to full back to a different prompting technique, something that would add lots of complexity


---------

Co-authored-by: Martin Brennan <[email protected]>
2024-03-27 14:30:11 +11:00
Discourse Translator Bot 82387cc51d Update translations (#547) 2024-03-26 10:21:23 -03:00
Martin Brennan c3b26ccb10 DEV: Move admin routes + templates to admin/assets/javascripts/ path (#545)
This ensures these routes and templates are not loaded if the user isn't
admin. Only affects Persona routes at this point in time.
2024-03-25 09:58:53 +10:00
Natalie Tay f3b3312ce0 FIX: Avoid replying to the reply user for llm_triage automation (#544)
Avoid replying to the reply user. This causes an infinite conversation from the reply_user to the reply_user
2024-03-22 12:34:18 +08:00
Jarek Radosz 1d476e3b68 DEV: Remove an unused template (#541) 2024-03-21 15:24:29 +01:00
Martin Brennan fb0d56324f FEATURE: Improve admin plugin UI and use new plugins show route (#512)
This commit changes Discourse AI's admin plugin page to use the new plugin
show route. The UI for persona editing has also been improved for consistency,
and other plugin UIs will follow suit:

Settings for the plugin are now listed in the plugin UI and can be changed
from there directly after core PR discourse/discourse#26154 is merged.

See also:

* https://github.com/discourse/discourse/pull/26024
* https://github.com/discourse/discourse/pull/26154
* https://github.com/discourse/discourse/pull/26254
2024-03-21 14:29:56 +10:00
Sam 5cac47a30a FIX: unify automation model translation (#540)
report runner and llm triage used different paths to figure out
underlying model name, unify so we use the same path.

fixes claude 3 based models on llm triage
2024-03-21 11:32:35 +11:00
Sam e8b2a200c1 FIX: prompt engineering for summary prompt (#539)
Prompt was steering incorrectly into the wrong language.

New prompt attempts to be more concise and clear and provides
better guidance about size of summary and how to format it.
2024-03-20 16:33:05 +11:00
Sam 41f1530078 FIX: mention suppression was not working right (#538)
We were only suppressing non mentions, ones that become spans.

@sam in the test was not resolving to a mention cause the user
did not exist.

depends on: https://github.com/discourse/discourse/pull/26253 for tests to pass.
2024-03-20 13:00:39 +11:00
Discourse Translator Bot 1a55d7f6e7 Update translations (#536) 2024-03-19 16:00:17 +01:00
Sam cc0369dd39 FEATURE: friendlier reply behavior in bot PMs (#535)
- Stop replying as bot, when human replies to another human
- Reply as correct persona when replying directly to a persona
- Fix paper cut where suppressing notifications was not doing so
2024-03-19 20:15:12 +11:00
Sam f62703760f FEATURE: add Claude 3 sonnet/haiku support for Amazon Bedrock (#534)
This PR consolidates the  implements new Anthropic Messages interface for Bedrock Claude endpoints and adds support for the new Claude 3 models (haiku, opus, sonnet).

Key changes:
- Renamed `AnthropicMessages` and `Anthropic` endpoint classes into a single `Anthropic` class (ditto for ClaudeMessages -> Claude)
- Updated `AwsBedrock` endpoints to use the new `/messages` API format for all Claude models
- Added `claude-3-haiku`, `claude-3-opus` and `claude-3-sonnet` model support in both Anthropic and AWS Bedrock endpoints
- Updated specs for the new consolidated endpoints and Claude 3 model support

This refactor removes support for old non messages API which has been deprecated by anthropic
2024-03-19 06:48:46 +11:00
Sam d7ed8180af FEATURE: allow suppression of notifications from report generation (#533)
* FEATURE: allow suppression of notifications from report generation

Previously we needed to do this by hand, unfortunately this uses up
too many tokens and is very hard to discover.

New option means that we can trivially disable notifications without
needing any prompt engineering.

* URI.parse is safer, use it
2024-03-16 08:05:03 +11:00
David Taylor dfc13fc631 UX: Add title suffix to shared AI pages (#531) 2024-03-15 10:31:58 +00:00
David Taylor 8b0e9984cf FIX: Syntax highlighting for shared-ai conversions with CSP enabled (#532)
- Adds a nonce to both script tags
- Removes the `onload=` inline script, and moves the tags to the end of the `<body>` instead. This provides the same UX (page will load and render, then hljs will be applied when ready)
2024-03-15 13:48:24 +11:00
Rafael dos Santos Silvaandwozulong 3b8f900486 FIX: Handle unicode on tokenizer (#515)
* FIX: Handle unicode on tokenizer

Our fast track code broke when strings had characters who are longer in tokens than
in UTF-8.

Admins can set `DISCOURSE_AI_STRICT_TOKEN_COUNTING: true` in app.yml to ensure token counting is strict, even if slower.


Co-authored-by: wozulong <[email protected]>
2024-03-14 17:33:30 -03:00
Rafael dos Santos Silva b327313115 DEV: Fix module namespace breaking reloads (#530) 2024-03-14 15:19:28 -03:00
Kris fc6c3c81e7 UX: add support for dark mode (#529) 2024-03-13 13:16:07 -04:00
Sam ea5c38686e FIX: expire assets when CSS changes FIX: missing translation on share page (#528)
* FIX: expire assets when CSS changes
FIX: missing translation on share page

Also add trivial image styling

* oops not needed
2024-03-13 20:52:33 +11:00
Sam 9d92dd76fb FIX: don't show share conversation incorrectly (#526)
* FIX: don't show share conversation incorrectly

- ai_persona_name can be null vs undefined leading to button showing up where it should not
- do not allow sharing of conversations where user is sending PMs to self

* remove erroneous code

* avoid query
2024-03-13 11:24:22 +11:00
Roman Rizzi c4ead89c6b FIX: Filter soft-deleted topics when backfilling sentiment (#527) 2024-03-12 21:01:24 -03:00
Kris 1c90ad81c2 UX: update styles and markup for share feature (#525) 2024-03-12 18:25:33 -04:00
Discourse Translator Bot 25d283b527 Update translations (#524) 2024-03-12 15:40:21 +01:00
Kelv d57212475c FIX: ai-image-caption should not crash on checking currentUser can_use_assistant (#523) 2024-03-12 16:40:30 +08:00
SamandMartin Brennan a03bc6ddec FEATURE: Share conversations with AI via a URL (#521)
This allows users to share a static page of an AI conversation with
the rest of the world.

By default this feature is disabled, it is enabled by turning on
ai_bot_allow_public_sharing via site settings

Precautions are taken when sharing

1. We make a carbonite copy
2. We minimize work generating page
3. We limit to 100 interactions
4. Many security checks - including disallowing if there is a mix
of users in the PM.

* Bonus commit, large PRs like this PR did not work with github tool
large objects would destroy context


Co-authored-by: Martin Brennan <[email protected]>
2024-03-12 16:51:41 +11:00
Keegan George 740731ab53 FIX: Image caption feature should respect composer AI helper groups (#522) 2024-03-11 15:35:20 -07:00
Sam 79638c2f50 FIX: Tune function calling (#519)
Adds support for "name" on functions which can be used for tool calls

For function calls we need to keep track of id/name and previously
we only supported either

Also attempts to improve sql helper
2024-03-09 08:46:40 +11:00
Keegan GeorgeandRafael Silva b515b4f66d FEATURE: AI Quick Semantic Search (#501)
This PR adds AI semantic search to the search pop available on every page.

It depends on several new and optional settings, like per post embeddings and a reranker model, so this is an experimental endeavour.


---------

Co-authored-by: Rafael Silva <[email protected]>
2024-03-08 13:02:50 -03:00
David Taylor 114b96f2b4 DEV: Update to new header API and FloatKit (#516) 2024-03-08 10:07:48 +00:00
Sam 936d246b7d FIX: Improve AI persona editor inputs and optional GitHub auth (#518)
1. Fix input fields in AI persona editor and make GitHub tool authentication optional

2. AI persona editor improvements and tool GitHub access token check

This pull request makes a few improvements:

- Adds `lang="en"` to number input fields in the AI persona editor to prevent localization issues 
- Adds `step="any"` to allow fractional values for temperature and top_p settings
- Makes GitHub tool authentication contingent on `ai_bot_github_access_token` site setting being present

see: https://meta.discourse.org/t/ai-bot-personas-don-t-accept-decimals-for-temperature-top-p/298243/7
2024-03-08 09:54:05 +11:00
Sam 2ad743d246 FEATURE: Add GitHub Helper AI Bot persona and tools (#513)
Introduces a new AI Bot persona called 'GitHub Helper' which is specialized in assisting with GitHub-related tasks and questions. It includes the following key changes:

- Implements the GitHub Helper persona class with its system prompt and available tools
   
- Adds three new AI Bot tools for GitHub interactions:
  - github_file_content: Retrieves content of files from a GitHub repository
  - github_pull_request_diff: Retrieves the diff for a GitHub pull request
  - github_search_code: Searches for code in a GitHub repository
    
- Updates the AI Bot dialects to support the new GitHub tools

- Implements multiple function calls for standard tool dialect
2024-03-08 06:37:23 +11:00
Rafael dos Santos Silva 176a4458f2 FIX: Prevent AI chat thread titles from being created before replies are posted (#517)
Chat thread replies draft trigger the thread_created event, which we relied on
to trigger the AI generated title. Because of that we now will use the noisier
chat_message_created event, and manually check for thread and replies existence.

See https://github.com/discourse/discourse/pull/26033
2024-03-07 16:14:17 -03:00
Selase Krakani 820e6d63ff DEV: Lower pinned version to fix unsupported API error on stable (#514)
Previous pinned version included references to new plugin API not
available on stable.

This new pinned version also contains 'backported' SSRF protection
security fix.
2024-03-07 14:30:05 +00:00
Loïc Guitaut 6ae4218a96 DEV: Fix new Rubocop offenses 2024-03-06 15:23:29 +01:00
Sam 8b382d6098 FEATURE: support for claude opus and sonnet (#508)
This provides new support for messages API from Claude.

It is required for latest model access.

Also corrects implementation of function calls.

* Fix message interleving

* fix broken spec

* add new models to automation
2024-03-06 06:04:37 +11:00
Discourse Translator Bot d7aeb1c731 Update translations (#510) 2024-03-05 12:02:46 -03:00
Sam b7a96e3bcb FIX: avoid all bot feedback loops (#507)
We need to ensure that under no circumstances feedback loops between
bots will emerge cause this can eat up a lot of tokens
2024-03-05 10:02:49 +11:00
Keegan George 9485f3110a UX: AI Helper positioning (#506) 2024-03-05 08:58:00 +11:00
Keegan George cee1b3d275 FIX: Backspace in composer custom prompt closes menu (#505) 2024-03-04 13:33:31 -08:00
SamandJoffrey JAFFEUX 77cf9e2cff FIX: system persona non English save, missing bot pms
- FIX: only update system attributes when updating system persona
- FIX: update participant count by hand so bot messages show in inbox
 

Co-authored-by: Joffrey JAFFEUX <[email protected]>
2024-03-04 09:56:59 +11:00
Sam c02794cf2e FIX: support multiple tool calls (#502)
* FIX: support multiple tool calls

Prior to this change we had a hard limit of 1 tool call per llm
round trip. This meant you could not google multiple things at
once or perform searches across two tools.

Also:

- Hint when Google stops working
- Log topic_id / post_id when performing completions

* Also track id for title
2024-03-02 07:53:21 +11:00
Kris b72ee805b6 DEV: update caption test for core interaction change (#503) 2024-03-01 14:58:33 -05:00
Sam 59bab2bba3 FIX: stream messages when directly PMing a persona (#500)
previous to this fix we did not consider personas a bot in the
front end
2024-03-01 07:53:42 +11:00
Sam 9fb1430e40 FIX: support spaces within arguments for Open AI (#499)
Previous to this fix if a tool call ever streamed a SPACE alone,
we would eat it and ignore it, breaking params

Also fixes some tests to ensure they are actually called :)
2024-02-29 12:47:34 +11:00
Rafael dos Santos Silva 1b72a00d2c FEATURE: Option for AI triage to send a post to the review queue (#498)
Option for AI triage to send a post to the review queue
2024-02-29 12:33:28 +11:00
Keegan George 6a30b06a55 DEV: Cancel popup should abort request (#497) 2024-02-28 13:32:45 -08:00
SamandMartin Brennan 484fd1435b DEV: improve internal design of ai persona and bug fix (#495)
* DEV: improve internal design of ai persona and bug fix

- Fixes bug where OpenAI could not describe images
- Fixes bug where mentionable personas could not be mentioned unless overarching bot was enabled
- Improves internal design of playground and bot to allow better for non "bot" users
- Allow PMs directly to persona users (previously bot user would also have to be in PM)
- Simplify internal code


Co-authored-by: Martin Brennan <[email protected]>
2024-02-28 16:46:32 +11:00
Keegan George a1b607db80 FIX: Composer service call breaking shared edits (#494) 2024-02-28 10:13:46 +11:00
Rafael dos Santos Silva a1f1067f69 FIX: Lower truncation size for Gemini Embeddings (#493) 2024-02-28 08:52:53 +11:00
Discourse Translator Bot 1414a91db4 Update translations (#492) 2024-02-27 16:32:29 -03:00
Selase Krakani fc2ffb9fec DEV: Pin plugin for Discourse < 3.3.0.beta1-dev (#491) 2024-02-27 16:32:13 -03:00
SamandRafael Silva d036f3fb8e FEATURE: AI helper support in non English languages (#489)
* FEATURE: AI helper support in non English languages

This attempts some prompt engineering to coerce AI helper to answer
in the appropriate language.

Note mileage will vary, in testing GPT-4 produces the best results
GPT-3.5 can return OKish results.

* Extend non english support for GPT-4V image caption

* Update db/fixtures/ai_helper/603_completion_prompts.rb

---------

Co-authored-by: Rafael Silva <[email protected]>
2024-02-27 16:31:51 -03:00
Sam aabff87501 FIX: image generation in gemini was broken (#490)
We need to inject blank model answers after tool calls if absent
otherwise model will reject it.
2024-02-27 18:24:30 +11:00
Keegan George 2c7d34ff1f DEV: Image caption system specs (#487) 2024-02-26 12:22:09 -08:00
Keegan George 0596ce41fe DEV: Auto save caption on mobile view (#486) 2024-02-23 10:06:39 -08:00
Kris 0f761234f9 UX: minor adjustments for image caption size, behavior (#484) 2024-02-22 12:31:25 -05:00
Discourse Translator Bot cbb3807237 Update translations (#480) 2024-02-22 10:50:35 -03:00
Roman Rizzi 94ba0dadc2 SECURITY: Place a SSRF protection when calling services from the plugin. (#485)
The Faraday adapter and `FinalDestionation::HTTP` will protect us from admin-initiated SSRF attacks when interacting with the external services powering this plugin features.:
2024-02-21 17:14:50 -03:00
Keegan George 97f3cba603 DEV: Add attribution to AI captioned images (#483) 2024-02-21 10:10:22 -08:00
Kris d88dceb49d UX: minor image caption style adjustments (#482) 2024-02-20 17:59:59 -05:00
Rafael dos Santos Silva bfd4fafaec FIX: Caption was broken with multiple subsequent calls (#481) 2024-02-20 12:44:15 -03:00
Sam becbe01f68 FIX: unable to share conversations with persona user (#479)
Persona users are still bots, but we were not properly accounting
for it and share icon was not showing up.

This depends on a core change that adds .topic to transformed posts
2024-02-20 16:16:23 +11:00
Rafael dos Santos Silva a5fe941e6a DEV: Update libraries for Ruby 3.3 compat (#477) 2024-02-20 13:45:16 +11:00
Martin Brennan 0c1aad7850 DEV: Cleanup caption endpoint and account for secure uploads (#478)
Utilizes the check for secure upload permissions from core PR
https://github.com/discourse/discourse/pull/25758 and cleans up
controller codes and spec code to reuse existing code and better
reflect reality.
2024-02-19 23:43:39 -03:00
Rafael dos Santos Silva cf19ce0d72 FEATURE: Handle secure uploads in image caption (#476) 2024-02-19 18:08:19 -03:00
Keegan GeorgeandRafael Silva a9b2d6a30a FEATURE: AI image caption (#470)
This PR adds a new feature where you can generate captions for images in the composer using AI.

---------

Co-authored-by: Rafael Silva <[email protected]>
2024-02-19 14:56:28 -03:00
378 changed files with 1016075 additions and 2230 deletions
+1
View File
@@ -1,3 +1,4 @@
< 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
+31 -7
View File
@@ -1,11 +1,30 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (7.1.3.2)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.6)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
drb (2.2.1)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
json (2.7.1)
language_server-protocol (3.17.0.3)
minitest (5.22.2)
mutex_m (0.2.0)
parallel (1.24.0)
parser (3.3.0.3)
parser (3.3.0.5)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
@@ -13,33 +32,38 @@ GEM
rainbow (3.1.1)
regexp_parser (2.9.0)
rexml (3.2.6)
rubocop (1.59.0)
rubocop (1.61.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.4)
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.30.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
rubocop-ast (1.31.1)
parser (>= 3.3.0.4)
rubocop-capybara (2.20.0)
rubocop (~> 1.41)
rubocop-discourse (3.6.0)
rubocop-discourse (3.7.1)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rspec (>= 2.25.0)
rubocop-factory_bot (2.25.1)
rubocop (~> 1.41)
rubocop-rspec (2.26.1)
rubocop-rspec (2.27.1)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0)
syntax_tree (6.2.0)
prettier_print (>= 1.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
PLATFORMS
@@ -0,0 +1,23 @@
import { AUTO_GROUPS } from "discourse/lib/constants";
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
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);
return record;
},
setupController(controller, model) {
this._super(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
);
},
});
@@ -2,7 +2,9 @@ import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model(params) {
const allPersonas = this.modelFor("adminPlugins.discourse-ai.ai-personas");
const allPersonas = this.modelFor(
"adminPlugins.show.discourse-ai-personas"
);
const id = parseInt(params.id, 10);
return allPersonas.findBy("id", id);
},
@@ -11,7 +13,7 @@ export default DiscourseRoute.extend({
this._super(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.discourse-ai.ai-personas")
this.modelFor("adminPlugins.show.discourse-ai-personas")
);
},
});
@@ -1,7 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model() {
export default class DiscourseAiAiPersonasRoute extends DiscourseRoute {
model() {
return this.store.findAll("ai-persona");
},
});
}
}
@@ -3,7 +3,10 @@
module DiscourseAi
module Admin
class AiPersonasController < ::Admin::AdminController
before_action :find_ai_persona, only: %i[show update destroy create_user]
requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_persona,
only: %i[show update destroy create_user indexing_status_check]
def index
ai_personas =
@@ -28,8 +31,10 @@ module DiscourseAi
end
def create
ai_persona = AiPersona.new(ai_persona_params)
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
if ai_persona.save
RagDocumentFragment.link_persona_and_uploads(ai_persona, attached_upload_ids)
render json: { ai_persona: ai_persona }, status: :created
else
render_json_error ai_persona
@@ -42,7 +47,9 @@ module DiscourseAi
end
def update
if @ai_persona.update(ai_persona_params)
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
RagDocumentFragment.update_persona_uploads(@ai_persona, attached_upload_ids)
render json: @ai_persona
else
render_json_error @ai_persona
@@ -57,12 +64,47 @@ module DiscourseAi
end
end
def upload_file
file = params[:file] || params[:files].first
if !SiteSetting.ai_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
def indexing_status_check
render json: RagDocumentFragment.indexing_status(@ai_persona, @ai_persona.uploads)
end
private
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(
@@ -77,7 +119,14 @@ module DiscourseAi
:user_id,
:mentionable,
:max_context_posts,
:vision_enabled,
:vision_max_pixels,
:rag_chunk_tokens,
:rag_chunk_overlap_tokens,
:rag_conversation_chunks,
:question_consolidator_llm,
allowed_group_ids: [],
rag_uploads: [:id],
)
if commands = params.dig(:ai_persona, :commands)
@@ -101,6 +150,31 @@ module DiscourseAi
end
end
end
def validate_extension!(filename)
extension = File.extname(filename)[1..-1] || ""
authorized_extensions = %w[txt md]
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
@@ -6,6 +6,21 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login
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: debug_info, status: 200
end
def stop_streaming_response
post = Post.find(params[:post_id])
guardian.ensure_can_see!(post)
@@ -0,0 +1,108 @@
# 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, :redirect_to_login_if_required, only: %i[show]
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.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.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 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
@@ -8,6 +8,8 @@ module DiscourseAi
before_action :ensure_can_request_suggestions
before_action :rate_limiter_performed!, except: %i[prompts]
include SecureUploadEndpointHelpers
def suggest
input = get_text_param!
@@ -105,6 +107,31 @@ module DiscourseAi
status: 502
end
def caption_image
image_url = params[:image_url]
raise Discourse::InvalidParameters.new(:image_url) if !image_url
image = upload_from_full_url(image_url)
raise Discourse::NotFound if image.blank?
final_image_url = get_caption_url(image, image_url)
hijack do
caption =
DiscourseAi::AiHelper::Assistant.new.generate_image_caption(
final_image_url,
current_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
def get_text_param!
@@ -120,14 +147,18 @@ module DiscourseAi
end
def ensure_can_request_suggestions
user_group_ids = current_user.group_ids
if !current_user.in_any_groups?(SiteSetting.ai_helper_allowed_groups_map)
raise Discourse::InvalidAccess
end
end
allowed =
SiteSetting.ai_helper_allowed_groups_map.any? do |group_id|
user_group_ids.include?(group_id)
end
def get_caption_url(image_upload, image_url)
if image_upload.secure?
check_secure_upload_permission(image_upload)
return Discourse.store.url_for(image_upload)
end
raise Discourse::InvalidAccess if !allowed
UrlHelper.absolute(image_url)
end
end
end
@@ -36,6 +36,34 @@ module DiscourseAi
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
end
end
def quick_search
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
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module SharedAiConversationsHelper
# bump up version when assets change
# long term we may want to change this cause it is hard to remember
# to bump versions, but for now this does the job
VERSION = "1"
def share_asset_url(short_path)
::UrlHelper.local_cdn_url("/plugins/discourse-ai/ai-share/#{short_path}?#{VERSION}")
end
end
end
end
+172
View File
@@ -0,0 +1,172 @@
# 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?
return if (ai_persona = AiPersona.find_by(id: args[:ai_persona_id])).nil?
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
vector_rep =
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
tokenizer = vector_rep.tokenizer
chunk_tokens = ai_persona.rag_chunk_tokens
overlap_tokens = ai_persona.rag_chunk_overlap_tokens
fragment_ids = RagDocumentFragment.where(ai_persona: ai_persona, upload: upload).pluck(:id)
# Check if this is the first time we process this upload.
if fragment_ids.empty?
document = get_uploaded_file(upload)
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!(
ai_persona: ai_persona,
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
overlap = tokenizer.decode(overlap_token_ids) + split_char
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(" ", 2).last
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)
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
@@ -0,0 +1,27 @@
# 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?
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
vector_rep =
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
# 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_rep.generate_representation_from(fragment) }
last_fragment = fragments.last
ai_persona = last_fragment.ai_persona
upload = last_fragment.upload
indexing_status = RagDocumentFragment.indexing_status(ai_persona, [upload])[upload.id]
RagDocumentFragment.publish_status(upload, indexing_status)
end
end
end
@@ -0,0 +1,51 @@
# 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
+1 -2
View File
@@ -12,8 +12,7 @@ module Jobs
topic = post.topic
reply_to = post.reply_to_post
guardian = Guardian.new(user)
return unless guardian.can_see?(post)
return unless user.guardian.can_see?(post)
prompt = CompletionPrompt.enabled_by_name("explain")
+1 -1
View File
@@ -6,7 +6,7 @@ module ::Jobs
def execute(args)
return unless bot_user = User.find_by(id: args[:bot_user_id])
return unless bot = DiscourseAi::AiBot::Bot.as(bot_user)
return unless bot = DiscourseAi::AiBot::Bot.as(bot_user, model: args[:model])
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE]
+4
View File
@@ -1,12 +1,16 @@
# frozen_string_literal: true
class AiApiAuditLog < ActiveRecord::Base
belongs_to :post
belongs_to :topic
module Provider
OpenAI = 1
Anthropic = 2
HuggingFaceTextGeneration = 3
Gemini = 4
Vllm = 5
Cohere = 6
end
end
+140 -65
View File
@@ -9,44 +9,30 @@ class AiPersona < ActiveRecord::Base
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
validate :system_persona_unchangeable, on: :update, if: :system
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 }
belongs_to :created_by, class_name: "User"
belongs_to :user
has_many :upload_references, as: :target, dependent: :destroy
has_many :uploads, through: :upload_references
has_many :rag_document_fragment, dependent: :destroy
has_many :rag_document_fragments, through: :ai_persona_rag_document_fragments
before_destroy :ensure_not_system
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 [](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
end
before_update :regenerate_rag_fragments
def self.persona_cache
@persona_cache ||= MultisiteHash.new("persona_cache")
@persona_cache ||= ::DiscourseAi::MultisiteHash.new("persona_cache")
end
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
@@ -60,21 +46,54 @@ class AiPersona < ActiveRecord::Base
.map(&:class_instance)
end
def self.mentionables
persona_cache[:mentionable_usernames] ||= AiPersona
.where(mentionable: true)
.where(enabled: true)
.joins(:user)
.pluck("ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm")
.map do |id, user_id, username, allowed_group_ids, default_llm|
{
id: id,
user_id: user_id,
username: username,
allowed_group_ids: allowed_group_ids,
default_llm: default_llm,
}
end
def self.persona_users(user: nil)
persona_users =
persona_cache[:persona_users] ||= AiPersona
.where(enabled: true)
.joins(:user)
.pluck(
"ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm, mentionable",
)
.map do |id, user_id, username, allowed_group_ids, default_llm, mentionable|
{
id: id,
user_id: user_id,
username: username,
allowed_group_ids: allowed_group_ids,
default_llm: default_llm,
mentionable: mentionable,
}
end
if user
persona_users.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) }
else
persona_users
end
end
def self.mentionables(user: nil)
all_mentionables =
persona_cache[:mentionable_usernames] ||= AiPersona
.where(mentionable: true)
.where(enabled: true)
.joins(:user)
.pluck("ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm")
.map do |id, user_id, username, allowed_group_ids, default_llm|
{
id: id,
user_id: user_id,
username: username,
allowed_group_ids: allowed_group_ids,
default_llm: default_llm,
}
end
if user
all_mentionables.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) }
else
all_mentionables
end
end
after_commit :bump_cache
@@ -91,6 +110,10 @@ class AiPersona < ActiveRecord::Base
mentionable = self.mentionable
default_llm = self.default_llm
max_context_posts = self.max_context_posts
vision_enabled = self.vision_enabled
vision_max_pixels = self.vision_max_pixels
rag_conversation_chunks = self.rag_conversation_chunks
question_consolidator_llm = self.question_consolidator_llm
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
if persona_class
@@ -122,6 +145,22 @@ class AiPersona < ActiveRecord::Base
max_context_posts
end
persona_class.define_singleton_method :vision_enabled do
vision_enabled
end
persona_class.define_singleton_method :vision_max_pixels do
vision_max_pixels
end
persona_class.define_singleton_method :question_consolidator_llm do
question_consolidator_llm
end
persona_class.define_singleton_method :rag_conversation_chunks do
rag_conversation_chunks
end
return persona_class
end
@@ -197,6 +236,22 @@ class AiPersona < ActiveRecord::Base
max_context_posts
end
define_singleton_method :vision_enabled do
vision_enabled
end
define_singleton_method :vision_max_pixels do
vision_max_pixels
end
define_singleton_method :rag_conversation_chunks do
rag_conversation_chunks
end
define_singleton_method :question_consolidator_llm do
question_consolidator_llm
end
define_singleton_method :to_s do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end
@@ -210,6 +265,10 @@ class AiPersona < ActiveRecord::Base
super(*args, **kwargs)
end
define_method :persona_id do
@ai_persona&.id
end
define_method :tools do
tools
end
@@ -229,6 +288,10 @@ class AiPersona < ActiveRecord::Base
define_method :system_prompt do
@ai_persona&.system_prompt || "You are a helpful bot."
end
define_method :uploads do
@ai_persona&.uploads
end
end
end
@@ -271,6 +334,12 @@ class AiPersona < ActiveRecord::Base
user
end
def regenerate_rag_fragments
if rag_chunk_tokens_changed? || rag_chunk_overlap_tokens_changed?
RagDocumentFragment.where(ai_persona: self).delete_all
end
end
private
def system_persona_unchangeable
@@ -292,26 +361,32 @@ end
#
# Table name: ai_personas
#
# id :bigint not null, primary key
# name :string(100) not null
# description :string(2000) not null
# commands :json 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
# mentionable :boolean default(FALSE), not null
# default_llm :text
# max_context_posts :integer
# max_post_context_tokens :integer
# max_context_tokens :integer
# id :bigint not null, primary key
# name :string(100) not null
# description :string(2000) not null
# commands :json 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
# mentionable :boolean default(FALSE), not null
# default_llm :text
# max_context_posts :integer
# max_post_context_tokens :integer
# max_context_tokens :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
# question_consolidator_llm :text
#
# Indexes
#
+15
View File
@@ -7,3 +7,18 @@ 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
#
+81
View File
@@ -0,0 +1,81 @@
# frozen_string_literal: true
class RagDocumentFragment < ActiveRecord::Base
belongs_to :upload
belongs_to :ai_persona
class << self
def link_persona_and_uploads(persona, upload_ids)
return if persona.blank?
return if upload_ids.blank?
return if !SiteSetting.ai_embeddings_enabled?
UploadReference.ensure_exist!(upload_ids: upload_ids, target: persona)
upload_ids.each do |upload_id|
Jobs.enqueue(:digest_rag_upload, ai_persona_id: persona.id, upload_id: upload_id)
end
end
def update_persona_uploads(persona, upload_ids)
return if persona.blank?
return if !SiteSetting.ai_embeddings_enabled?
if upload_ids.blank?
RagDocumentFragment.where(ai_persona: persona).destroy_all
UploadReference.where(target: persona).destroy_all
else
RagDocumentFragment.where(ai_persona: persona).where.not(upload_id: upload_ids).destroy_all
link_persona_and_uploads(persona, upload_ids)
end
end
def indexing_status(persona, uploads)
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
vector_rep =
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
embeddings_table = vector_rep.rag_fragments_table_name
results = DB.query(<<~SQL, persona_id: persona.id, upload_ids: uploads.map(&:id))
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.ai_persona_id = :persona_id
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
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/ai-persona-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
# ai_persona_id :integer not null
# fragment_number :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# metadata :text
#
+215
View File
@@ -0,0 +1,215 @@
# 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
::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,
text_entities: true,
strip_links: true,
strip_details: true,
)
html << "<p><b>#{post.user.username}</b>: #{text}</p>"
if html.length > 1000
html << "<p>...</p>"
break
end
end
html << "<a href='#{url}'>#{I18n.t("discourse_ai.share_ai.read_more")}<a>"
html
end
def onebox
<<~HTML
<div>
<aside class="onebox allowlistedgeneric" data-onebox-src="#{url}">
<header class="source">
<span class="onebox-ai-llm-title">#{I18n.t("discourse_ai.share_ai.onebox_title", llm_name: llm_name)}</span>
<a href="#{url}" target="_blank" rel="nofollow ugc noopener" tabindex="-1">#{Discourse.base_uri}</a>
</header>
<article class="onebox-body">
<h3><a href="#{url}" rel="nofollow ugc noopener" tabindex="-1">#{title}</a></h3>
#{html_excerpt}
</article>
<div style="clear: both"></div>
</aside>
</div>
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)
llm_name = nil
topic.topic_allowed_users.each do |tu|
if DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(tu.user_id)
llm_name = DiscourseAi::AiBot::EntryPoint.find_bot_by_id(tu.user_id)&.llm
end
end
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: post.cooked,
}
mapped[:persona] = persona if ::DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(
post.user_id,
)
mapped[:username] = post.user&.username if include_usernames
mapped
end,
}
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
#
@@ -17,9 +17,20 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
:mentionable,
:default_llm,
:user_id,
:max_context_posts
:max_context_posts,
:vision_enabled,
:vision_max_pixels,
:rag_chunk_tokens,
:rag_chunk_overlap_tokens,
:rag_conversation_chunks,
:question_consolidator_llm
has_one :user, serializer: BasicUserSerializer, embed: :object
has_many :rag_uploads, serializer: UploadSerializer, embed: :object
def rag_uploads
object.uploads
end
def name
object.class_instance.name
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("discourse-ai", function () {
this.route("ai-personas", function () {
this.route("new");
this.route("show", { path: "/:id" });
});
});
},
};
@@ -0,0 +1,12 @@
export default {
resource: "admin.adminPlugins.show",
path: "/plugins",
map() {
this.route("discourse-ai-personas", { path: "ai-personas" }, function () {
this.route("new");
this.route("show", { path: "/:id" });
});
},
};
@@ -2,7 +2,8 @@ import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import RestModel from "discourse/models/rest";
const ATTRIBUTES = [
const CREATE_ATTRIBUTES = [
"id",
"name",
"description",
"commands",
@@ -18,6 +19,33 @@ const ATTRIBUTES = [
"default_llm",
"user",
"max_context_posts",
"vision_enabled",
"vision_max_pixels",
"rag_uploads",
"rag_chunk_tokens",
"rag_chunk_overlap_tokens",
"rag_conversation_chunks",
"question_consolidator_llm",
];
const SYSTEM_ATTRIBUTES = [
"id",
"allowed_group_ids",
"enabled",
"system",
"priority",
"user_id",
"mentionable",
"default_llm",
"user",
"max_context_posts",
"vision_enabled",
"vision_max_pixels",
"rag_uploads",
"rag_chunk_tokens",
"rag_chunk_overlap_tokens",
"rag_conversation_chunks",
"question_consolidator_llm",
];
class CommandOption {
@@ -97,19 +125,24 @@ export default class AiPersona extends RestModel {
}
updateProperties() {
let attrs = this.getProperties(ATTRIBUTES);
let attrs = this.system
? this.getProperties(SYSTEM_ATTRIBUTES)
: this.getProperties(CREATE_ATTRIBUTES);
attrs.id = this.id;
this.populateCommandOptions(attrs);
return attrs;
}
createProperties() {
let attrs = this.getProperties(ATTRIBUTES);
let attrs = this.getProperties(CREATE_ATTRIBUTES);
this.populateCommandOptions(attrs);
return attrs;
}
workingCopy() {
return AiPersona.create(this.createProperties());
let attrs = this.getProperties(CREATE_ATTRIBUTES);
this.populateCommandOptions(attrs);
return AiPersona.create(attrs);
}
}
@@ -0,0 +1,50 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { gt } from "truth-helpers";
import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n";
import DMenu from "float-kit/components/d-menu";
import { composeAiBotMessage } from "../lib/ai-bot-helper";
import AiBotHeaderPanel from "./ai-bot-header-panel";
export default class AiBotHeaderIcon extends Component {
@service siteSettings;
@service composer;
get bots() {
return this.siteSettings.ai_bot_add_to_header
? this.siteSettings.ai_bot_enabled_chat_bots.split("|").filter(Boolean)
: [];
}
@action
compose() {
composeAiBotMessage(this.bots[0], this.composer);
}
<template>
{{#if (gt this.bots.length 0)}}
<li>
{{#if (gt this.bots.length 1)}}
<DMenu
@icon="robot"
@title={{i18n "discourse_ai.ai_bot.shortcut_title"}}
class="ai-bot-button icon btn-flat"
>
<:content as |args|>
<AiBotHeaderPanel @closePanel={{args.close}} />
</:content>
</DMenu>
{{else}}
<DButton
@icon="robot"
@title={{i18n "discourse_ai.ai_bot.shortcut_title"}}
class="ai-bot-button icon btn-flat"
@action={{this.compose}}
/>
{{/if}}
</li>
{{/if}}
</template>
}

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