Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e6ced4554 |
@@ -14,15 +14,6 @@ module DiscourseAi
|
||||
|
||||
render json: {}, status: 200
|
||||
end
|
||||
|
||||
def show_bot_username
|
||||
bot_user_id = DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(params[:username])
|
||||
raise Discourse::InvalidParameters.new(:username) if !bot_user_id
|
||||
|
||||
bot_username_lower = User.find(bot_user_id).username_lower
|
||||
|
||||
render json: { bot_username: bot_username_lower }, status: 200
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PostCustomPrompt < ActiveRecord::Base
|
||||
belongs_to :post
|
||||
end
|
||||
|
||||
class ::Post
|
||||
has_one :post_custom_prompt, dependent: :destroy
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
<div class="bot-panel ai-bot-available-bot-options">
|
||||
<div class="menu-panel drop-down">
|
||||
<div class="panel-body">
|
||||
<div class="panel-body-contents">
|
||||
<div class="sidebar-hamburger-dropdown">
|
||||
{{#each this.botNames as |bot|}}
|
||||
<DButton
|
||||
@class="btn-flat ai-bot-available-bot-content"
|
||||
@translatedTitle={{bot.humanized}}
|
||||
@translatedLabel={{bot.humanized}}
|
||||
@action={{action "composeMessageWithTargetBot" bot.modelName}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,34 +0,0 @@
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import Component from "@glimmer/component";
|
||||
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
|
||||
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class AiBotHeaderPanel extends Component {
|
||||
@service siteSettings;
|
||||
@service composer;
|
||||
|
||||
@action
|
||||
async composeMessageWithTargetBot(target) {
|
||||
this.#composeAiBotMessage(target);
|
||||
}
|
||||
|
||||
get botNames() {
|
||||
return this.enabledBotOptions.map((bot) => {
|
||||
return {
|
||||
humanized: I18n.t(`discourse_ai.ai_bot.bot_names.${bot}`),
|
||||
modelName: bot,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get enabledBotOptions() {
|
||||
return this.siteSettings.ai_bot_enabled_chat_bots.split("|");
|
||||
}
|
||||
|
||||
async #composeAiBotMessage(targetBot) {
|
||||
this.args.closePanel();
|
||||
composeAiBotMessage(targetBot, this.composer);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import Composer from "discourse/models/composer";
|
||||
import I18n from "I18n";
|
||||
|
||||
export async function composeAiBotMessage(targetBot, composer) {
|
||||
let botUsername = await ajax("/discourse-ai/ai-bot/bot-username", {
|
||||
data: { username: targetBot },
|
||||
}).then((data) => {
|
||||
return data.bot_username;
|
||||
});
|
||||
|
||||
composer.focusComposer({
|
||||
fallbackToNewTopic: true,
|
||||
openOpts: {
|
||||
action: Composer.PRIVATE_MESSAGE,
|
||||
recipients: botUsername,
|
||||
topicTitle: I18n.t("discourse_ai.ai_bot.default_pm_prefix"),
|
||||
archetypeId: "private_message",
|
||||
draftKey: Composer.NEW_PRIVATE_MESSAGE_KEY,
|
||||
hasGroups: false,
|
||||
warningsDisabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import Widget from "discourse/widgets/widget";
|
||||
import RenderGlimmer from "discourse/widgets/render-glimmer";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default class AiBotHeaderPanelWrapper extends Widget {
|
||||
buildAttributes() {
|
||||
return { "data-click-outside": true };
|
||||
}
|
||||
|
||||
html() {
|
||||
return [
|
||||
new RenderGlimmer(
|
||||
this,
|
||||
"div.widget-component-connector",
|
||||
hbs`<AiBotHeaderPanel @closePanel={{@data.closePanel}} />`,
|
||||
{ closePanel: this.closePanel }
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@action
|
||||
closePanel() {
|
||||
this.sendWidgetAction("hideAiBotPanel");
|
||||
}
|
||||
|
||||
@action
|
||||
clickOutside() {
|
||||
this.closePanel();
|
||||
}
|
||||
}
|
||||
@@ -3,60 +3,11 @@ import { cookAsync } from "discourse/lib/text";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import loadScript from "discourse/lib/load-script";
|
||||
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
|
||||
|
||||
function isGPTBot(user) {
|
||||
return user && [-110, -111, -112].includes(user.id);
|
||||
}
|
||||
|
||||
function attachHeaderIcon(api) {
|
||||
const settings = api.container.lookup("service:site-settings");
|
||||
|
||||
const enabledBots = settings.ai_helper_add_ai_pm_to_header
|
||||
? settings.ai_bot_enabled_chat_bots.split("|").filter(Boolean)
|
||||
: [];
|
||||
if (enabledBots.length > 0) {
|
||||
api.attachWidgetAction("header", "showAiBotPanel", function () {
|
||||
this.state.botSelectorVisible = true;
|
||||
});
|
||||
|
||||
api.attachWidgetAction("header", "hideAiBotPanel", function () {
|
||||
this.state.botSelectorVisible = false;
|
||||
});
|
||||
|
||||
api.decorateWidget("header-icons:before", (helper) => {
|
||||
return helper.attach("header-dropdown", {
|
||||
title: "discourse_ai.ai_bot.shortcut_title",
|
||||
icon: "robot",
|
||||
action: "clickStartAiBotChat",
|
||||
active: false,
|
||||
classNames: ["ai-bot-button"],
|
||||
});
|
||||
});
|
||||
|
||||
if (enabledBots.length === 1) {
|
||||
api.attachWidgetAction("header", "clickStartAiBotChat", function () {
|
||||
composeAiBotMessage(
|
||||
enabledBots[0],
|
||||
api.container.lookup("service:composer")
|
||||
);
|
||||
});
|
||||
} else {
|
||||
api.attachWidgetAction("header", "clickStartAiBotChat", function () {
|
||||
this.sendWidgetAction("showAiBotPanel");
|
||||
});
|
||||
}
|
||||
|
||||
api.addHeaderPanel(
|
||||
"ai-bot-header-panel-wrapper",
|
||||
"botSelectorVisible",
|
||||
function () {
|
||||
return {};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAIBotReplies(api) {
|
||||
api.addPostMenuButton("cancel-gpt", (post) => {
|
||||
if (isGPTBot(post.user)) {
|
||||
@@ -143,19 +94,10 @@ export default {
|
||||
|
||||
initialize(container) {
|
||||
const settings = container.lookup("service:site-settings");
|
||||
const user = container.lookup("service:current-user");
|
||||
const aiBotEnaled =
|
||||
settings.discourse_ai_enabled && settings.ai_bot_enabled;
|
||||
|
||||
const aiBotsAllowedGroups = settings.ai_bot_allowed_groups
|
||||
.split("|")
|
||||
.map(parseInt);
|
||||
const canInteractWithAIBots = user?.groups.some((g) =>
|
||||
aiBotsAllowedGroups.includes(g.id)
|
||||
);
|
||||
|
||||
if (aiBotEnaled && canInteractWithAIBots) {
|
||||
withPluginApi("1.6.0", attachHeaderIcon);
|
||||
if (aiBotEnaled) {
|
||||
withPluginApi("1.6.0", initializeAIBotReplies);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,16 +33,12 @@ export default {
|
||||
name: "discourse-ai-topic-summary",
|
||||
|
||||
initialize(container) {
|
||||
const user = container.lookup("service:current-user");
|
||||
const settings = container.lookup("service:site-settings");
|
||||
const summarizationEnabled =
|
||||
settings.discourse_ai_enabled && settings.ai_summarization_enabled;
|
||||
|
||||
if (user) {
|
||||
const settings = container.lookup("service:site-settings");
|
||||
const summarizationEnabled =
|
||||
settings.discourse_ai_enabled && settings.ai_summarization_enabled;
|
||||
|
||||
if (summarizationEnabled) {
|
||||
withPluginApi("1.6.0", initializeTopicSummary);
|
||||
}
|
||||
if (summarizationEnabled) {
|
||||
withPluginApi("1.6.0", initializeTopicSummary);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,18 +5,3 @@ nav.post-controls .actions button.cancel-streaming {
|
||||
article.streaming nav.post-controls .actions button.cancel-streaming {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ai-bot-available-bot-options {
|
||||
.ai-bot-available-bot-content {
|
||||
color: var(--primary-high);
|
||||
display: flex;
|
||||
width: 100%;
|
||||
.d-button-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
&:hover {
|
||||
background: var(--primary-low);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-ai-summarization {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.topic-ai-summarization {
|
||||
margin-left: 10px;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.toggle-summary {
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topic-ai-summarization {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,7 @@ en:
|
||||
other: "Last %{count} hours"
|
||||
|
||||
ai_bot:
|
||||
cancel_streaming: "Stop reply"
|
||||
default_pm_prefix: "[Untitled AI bot PM]"
|
||||
shortcut_title: "Start a PM with an AI bot"
|
||||
|
||||
bot_names:
|
||||
gpt-4: "GPT-4"
|
||||
gpt-3:
|
||||
5-turbo: "GPT-3.5"
|
||||
claude-v1: "Claude V1"
|
||||
cancel_streaming: Stop reply
|
||||
|
||||
|
||||
review:
|
||||
|
||||
@@ -47,10 +47,10 @@ en:
|
||||
ai_embeddings_generate_for_pms: "Generate embeddings for personal messages."
|
||||
ai_embeddings_semantic_related_topics_enabled: "Use Semantic Search for related topics."
|
||||
ai_embeddings_semantic_related_topics: "Maximum number of topics to show in related topic section."
|
||||
ai_embeddings_semantic_related_topics_include_closed_topics: "Include closed topics in related topics."
|
||||
ai_embeddings_pg_connection_string: "PostgreSQL connection string for the embeddings module. Needs pgvector extension enabled and a series of tables created. See docs for more info."
|
||||
ai_embeddings_semantic_search_model: "Model to use for semantic search."
|
||||
ai_embeddings_semantic_search_enabled: "Enable full-page semantic search."
|
||||
ai_embeddings_semantic_related_include_closed_topics: "Include closed topics in semantic search results"
|
||||
|
||||
ai_summarization_enabled: "Enable the summarization module."
|
||||
ai_summarization_discourse_service_api_endpoint: "URL where the Discourse summarization API is running."
|
||||
@@ -61,14 +61,7 @@ en:
|
||||
ai_bot_enabled: "Enable the AI Bot module."
|
||||
ai_bot_allowed_groups: "When the GPT Bot has access to the PM, it will reply to members of these groups."
|
||||
ai_bot_enabled_chat_bots: "Available models to act as an AI Bot"
|
||||
ai_helper_add_ai_pm_to_header: "Display a button in the header to start a PM with a AI Bot"
|
||||
|
||||
ai_stability_api_key: "API key for the stability.ai API"
|
||||
ai_stability_engine: "Image generation ngine to use for the stability.ai API"
|
||||
ai_stability_api_url: "URL for the stability.ai API"
|
||||
|
||||
ai_google_custom_search_api_key: "API key for the Google Custom Search API see: https://developers.google.com/custom-search"
|
||||
ai_google_custom_search_cx: "CX for Google Custom Search API"
|
||||
|
||||
reviewables:
|
||||
reasons:
|
||||
@@ -88,31 +81,3 @@ en:
|
||||
generate_titles: Suggest topic titles
|
||||
proofread: Proofread text
|
||||
markdown_table: Generate Markdown table
|
||||
|
||||
ai_bot:
|
||||
default_pm_prefix: "[Untitled AI bot PM]"
|
||||
topic_not_found: "Summary unavailable, topic not found!"
|
||||
command_summary:
|
||||
categories: "List categories"
|
||||
search: "Search"
|
||||
tags: "List tags"
|
||||
time: "Time"
|
||||
summarize: "Summarize"
|
||||
image: "Generate image"
|
||||
google: "Search Google"
|
||||
command_description:
|
||||
time: "Time in %{timezone} is %{time}"
|
||||
summarize: "Summarized <a href='%{url}'>%{title}</a>"
|
||||
image: "Prompt: %{prompt}"
|
||||
categories:
|
||||
one: "Found %{count} category"
|
||||
other: "Found %{count} categories"
|
||||
tags:
|
||||
one: "Found %{count} tag"
|
||||
other: "Found %{count} tags"
|
||||
search:
|
||||
one: "Found %{count} <a href='%{url}'>result</a> for '%{query}'"
|
||||
other: "Found %{count} <a href='%{url}'>results</a> for '%{query}'"
|
||||
google:
|
||||
one: "Found %{count} <a href='%{url}'>result</a> for '%{query}'"
|
||||
other: "Found %{count} <a href='%{url}'>results</a> for '%{query}'"
|
||||
|
||||
@@ -16,7 +16,6 @@ DiscourseAi::Engine.routes.draw do
|
||||
|
||||
scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do
|
||||
post "post/:post_id/stop-streaming" => "bot#stop_streaming_response"
|
||||
get "bot-username" => "bot#show_bot_username"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+4
-26
@@ -94,23 +94,6 @@ plugins:
|
||||
ai_anthropic_api_key:
|
||||
default: ""
|
||||
secret: true
|
||||
ai_stability_api_key:
|
||||
default: ""
|
||||
secret: true
|
||||
ai_stability_api_url:
|
||||
default: "https://api.stability.ai"
|
||||
ai_stability_engine:
|
||||
default: "stable-diffusion-xl-beta-v2-2-2"
|
||||
type: enum
|
||||
choices:
|
||||
- "stable-diffusion-xl-beta-v2-2-2"
|
||||
- "stable-diffusion-v1-5"
|
||||
|
||||
ai_google_custom_search_api_key:
|
||||
default: ""
|
||||
secret: true
|
||||
ai_google_custom_search_cx:
|
||||
default: ""
|
||||
|
||||
composer_ai_helper_enabled:
|
||||
default: false
|
||||
@@ -169,15 +152,13 @@ plugins:
|
||||
- msmarco-distilbert-base-v4
|
||||
- msmarco-distilbert-base-tas-b
|
||||
- text-embedding-ada-002
|
||||
ai_embeddings_semantic_related_instruction:
|
||||
default: "Represent the Discourse topic for retrieving relevant topics:"
|
||||
hidden: true
|
||||
client: false
|
||||
ai_embeddings_generate_for_pms: false
|
||||
ai_embeddings_semantic_related_topics_enabled: false
|
||||
ai_embeddings_semantic_related_topics: 5
|
||||
ai_embeddings_semantic_related_include_closed_topics: true
|
||||
ai_embeddings_pg_connection_string: ""
|
||||
ai_embeddings_pg_connection_string:
|
||||
default: ""
|
||||
secret: true
|
||||
ai_embeddings_semantic_search_enabled:
|
||||
default: false
|
||||
client: true
|
||||
@@ -211,7 +192,7 @@ plugins:
|
||||
type: group_list
|
||||
list_type: compact
|
||||
default: "3|14" # 3: @staff, 14: @trust_level_4
|
||||
# Adding a new bot? Make sure to create a user for it on the seed file and update translations.
|
||||
# Adding a new bot? Make sure to create a user for it on the seed file.
|
||||
ai_bot_enabled_chat_bots:
|
||||
type: list
|
||||
default: "gpt-3.5-turbo"
|
||||
@@ -220,6 +201,3 @@ plugins:
|
||||
- gpt-3.5-turbo
|
||||
- gpt-4
|
||||
- claude-v1
|
||||
ai_helper_add_ai_pm_to_header:
|
||||
default: true
|
||||
client: true
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PostCustomPrompts < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :post_custom_prompts do |t|
|
||||
t.integer :post_id, null: false
|
||||
t.json :custom_prompt, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :post_custom_prompts, :post_id, unique: true
|
||||
end
|
||||
end
|
||||
@@ -7,61 +7,39 @@ module DiscourseAi
|
||||
bot_user.id == DiscourseAi::AiBot::EntryPoint::CLAUDE_V1_ID
|
||||
end
|
||||
|
||||
def bot_prompt_with_topic_context(post, triage: false)
|
||||
super(post, triage: triage).join("\n\n")
|
||||
def bot_prompt_with_topic_context(post)
|
||||
super(post).join("\n\n")
|
||||
end
|
||||
|
||||
def prompt_limit
|
||||
7500 # https://console.anthropic.com/docs/prompt-design#what-is-a-prompt
|
||||
end
|
||||
|
||||
def get_delta(partial, context)
|
||||
context[:pos] ||= 0
|
||||
|
||||
full = partial[:completion]
|
||||
delta = full[context[:pos]..-1]
|
||||
|
||||
context[:pos] = full.length
|
||||
delta
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_message(poster_username, content, system: false, last: false)
|
||||
def build_message(poster_username, content)
|
||||
role = poster_username == bot_user.username ? "Assistant" : "Human"
|
||||
|
||||
result = +"#{role}: #{content}"
|
||||
result << "\nAssistant: " if last
|
||||
|
||||
result
|
||||
"#{role}: #{content}"
|
||||
end
|
||||
|
||||
def model_for
|
||||
"claude-v1"
|
||||
end
|
||||
|
||||
def get_updated_title(prompt)
|
||||
DiscourseAi::Inference::AnthropicCompletions.perform!(
|
||||
prompt,
|
||||
model_for,
|
||||
temperature: 0.7,
|
||||
max_tokens: 40,
|
||||
).dig(:completion)
|
||||
def update_with_delta(_, partial)
|
||||
partial[:completion]
|
||||
end
|
||||
|
||||
def submit_prompt(prompt, max_tokens: nil, temperature: nil, prefer_low_cost: false, &blk)
|
||||
def submit_prompt_and_stream_reply(prompt, &blk)
|
||||
DiscourseAi::Inference::AnthropicCompletions.perform!(
|
||||
prompt,
|
||||
model_for,
|
||||
temperature: temperature || 0.4,
|
||||
max_tokens: max_tokens || 3000,
|
||||
temperature: 0.4,
|
||||
max_tokens: 3000,
|
||||
&blk
|
||||
)
|
||||
end
|
||||
|
||||
def tokenize(text)
|
||||
DiscourseAi::Tokenizer::AnthropicTokenizer.tokenize(text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+46
-268
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi::AiBot::Commands
|
||||
class CategoriesCommand < Command
|
||||
class << self
|
||||
def name
|
||||
"categories"
|
||||
end
|
||||
|
||||
def desc
|
||||
"!categories - will list the categories on the current discourse instance"
|
||||
end
|
||||
end
|
||||
|
||||
def result_name
|
||||
"Category list is"
|
||||
end
|
||||
|
||||
def description_args
|
||||
{ count: @last_count || 0 }
|
||||
end
|
||||
|
||||
def process
|
||||
columns = {
|
||||
name: "Name",
|
||||
slug: "Slug",
|
||||
description: "Description",
|
||||
posts_year: "Posts Year",
|
||||
posts_month: "Posts Month",
|
||||
posts_week: "Posts Week",
|
||||
id: "id",
|
||||
parent_category_id: "parent_category_id",
|
||||
}
|
||||
|
||||
rows = Category.where(read_restricted: false).limit(100).pluck(*columns.keys)
|
||||
@count = rows.length
|
||||
|
||||
format_results(rows, columns.values)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,109 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module AiBot
|
||||
module Commands
|
||||
class Command
|
||||
class << self
|
||||
def name
|
||||
raise NotImplemented
|
||||
end
|
||||
|
||||
def should_invoke?(cmd_name)
|
||||
cmd_name == name
|
||||
end
|
||||
|
||||
def desc
|
||||
raise NotImplemented
|
||||
end
|
||||
|
||||
def extra_context
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :bot_user, :args, :post
|
||||
|
||||
def initialize(bot_user, post, args)
|
||||
@bot_user = bot_user
|
||||
@args = args
|
||||
@post = post
|
||||
end
|
||||
|
||||
def bot
|
||||
@bot ||= DiscourseAi::AiBot::Bot.as(bot_user)
|
||||
end
|
||||
|
||||
def result_name
|
||||
raise NotImplemented
|
||||
end
|
||||
|
||||
def name
|
||||
raise NotImplemented
|
||||
end
|
||||
|
||||
def process
|
||||
raise NotImplemented
|
||||
end
|
||||
|
||||
def description_args
|
||||
{}
|
||||
end
|
||||
|
||||
def chain_next_response
|
||||
true
|
||||
end
|
||||
|
||||
def pre_raw_details
|
||||
I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")
|
||||
end
|
||||
|
||||
def post_raw_details
|
||||
+<<~HTML
|
||||
<details>
|
||||
<summary>#{I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")}</summary>
|
||||
<p>
|
||||
#{I18n.t("discourse_ai.ai_bot.command_description.#{self.class.name}", self.description_args)}
|
||||
</p>
|
||||
</details>
|
||||
HTML
|
||||
end
|
||||
|
||||
def format_results(rows, column_names = nil)
|
||||
rows = rows.map { |row| yield row } if block_given?
|
||||
|
||||
if !column_names
|
||||
index = -1
|
||||
column_indexes = {}
|
||||
|
||||
rows =
|
||||
rows.map do |data|
|
||||
new_row = []
|
||||
data.each do |key, value|
|
||||
found_index = column_indexes[key.to_s] ||= (index += 1)
|
||||
new_row[found_index] = value
|
||||
end
|
||||
new_row
|
||||
end
|
||||
column_names = column_indexes.keys
|
||||
end
|
||||
# two tokens per delimiter is a reasonable balance
|
||||
# there may be a single delimiter solution but GPT has
|
||||
# a hard time dealing with escaped characters
|
||||
delimiter = "¦"
|
||||
formatted = +""
|
||||
formatted << column_names.join(delimiter)
|
||||
formatted << "\n"
|
||||
|
||||
rows.each do |array|
|
||||
array.map! { |item| item.to_s.gsub(delimiter, "|").gsub(/\n/, " ") }
|
||||
formatted << array.join(delimiter)
|
||||
formatted << "\n"
|
||||
end
|
||||
|
||||
formatted
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,56 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi::AiBot::Commands
|
||||
class GoogleCommand < Command
|
||||
class << self
|
||||
def name
|
||||
"google"
|
||||
end
|
||||
|
||||
def desc
|
||||
"!google SEARCH_QUERY - will search using Google (supports all Google search operators)"
|
||||
end
|
||||
end
|
||||
|
||||
def result_name
|
||||
"Google Results"
|
||||
end
|
||||
|
||||
def description_args
|
||||
{
|
||||
count: @last_num_results || 0,
|
||||
query: @last_query || "",
|
||||
url: "https://google.com/search?q=#{CGI.escape(@last_query || "")}",
|
||||
}
|
||||
end
|
||||
|
||||
def process
|
||||
@last_query = @args
|
||||
api_key = SiteSetting.ai_google_custom_search_api_key
|
||||
cx = SiteSetting.ai_google_custom_search_cx
|
||||
query = CGI.escape(@args)
|
||||
uri =
|
||||
URI("https://www.googleapis.com/customsearch/v1?key=#{api_key}&cx=#{cx}&q=#{query}&num=10")
|
||||
body = Net::HTTP.get(uri)
|
||||
|
||||
parse_search_json(body).to_s
|
||||
end
|
||||
|
||||
def parse_search_json(json_data)
|
||||
parsed = JSON.parse(json_data)
|
||||
results = parsed["items"]
|
||||
|
||||
@last_num_results = parsed.dig("searchInformation", "totalResults").to_i
|
||||
|
||||
format_results(results) do |result|
|
||||
{
|
||||
title: result["title"],
|
||||
link: result["link"],
|
||||
snippet: result["snippet"],
|
||||
displayLink: result["displayLink"],
|
||||
formattedUrl: result["formattedUrl"],
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,55 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi::AiBot::Commands
|
||||
class ImageCommand < Command
|
||||
class << self
|
||||
def name
|
||||
"image"
|
||||
end
|
||||
|
||||
def desc
|
||||
"!image DESC - renders an image from the description (remove all connector words, keep it to 40 words or less, be creative)"
|
||||
end
|
||||
end
|
||||
|
||||
def result_name
|
||||
"results"
|
||||
end
|
||||
|
||||
def description_args
|
||||
{ prompt: @args || 0 }
|
||||
end
|
||||
|
||||
def chain_next_response
|
||||
false
|
||||
end
|
||||
|
||||
def post_raw_details
|
||||
"#{super}\n\n#{@last_custom_raw}"
|
||||
end
|
||||
|
||||
def process
|
||||
prompt = @args
|
||||
|
||||
results = DiscourseAi::Inference::StabilityGenerator.perform!(prompt)
|
||||
|
||||
uploads = []
|
||||
|
||||
results[:artifacts].each_with_index do |image, i|
|
||||
f = Tempfile.new("v1_txt2img_#{i}.png")
|
||||
f.binmode
|
||||
f.write(Base64.decode64(image[:base64]))
|
||||
f.rewind
|
||||
uploads << UploadCreator.new(f, "image.png").create_for(bot_user.id)
|
||||
f.unlink
|
||||
end
|
||||
|
||||
@last_custom_raw =
|
||||
uploads
|
||||
.map { |upload| "" }
|
||||
.join(" ")
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,27 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi::AiBot::Commands
|
||||
class NoopCommand < Command
|
||||
class << self
|
||||
def name
|
||||
"noop"
|
||||
end
|
||||
|
||||
def desc
|
||||
"!noop - does nothing, catch all command when you do not know how to triage"
|
||||
end
|
||||
end
|
||||
|
||||
def post_raw_details
|
||||
nil
|
||||
end
|
||||
|
||||
def pre_raw_details
|
||||
nil
|
||||
end
|
||||
|
||||
def process
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,128 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi::AiBot::Commands
|
||||
class SearchCommand < Command
|
||||
class << self
|
||||
def name
|
||||
"search"
|
||||
end
|
||||
|
||||
def desc
|
||||
"!search SEARCH_QUERY - will search topics in the current discourse instance"
|
||||
end
|
||||
|
||||
def extra_context
|
||||
<<~TEXT
|
||||
Discourse search supports, the following special filters:
|
||||
|
||||
user:USERNAME: only posts created by a specific user
|
||||
in:tagged: has at least 1 tag
|
||||
in:untagged: has no tags
|
||||
in:title: has the search term in the title
|
||||
status:open: not closed or archived
|
||||
status:closed: closed
|
||||
status:archived: archived
|
||||
status:single_user: only a single user posted on the topic
|
||||
post_count:X: only topics with X amount of posts
|
||||
min_posts:X: topics containing a minimum of X posts
|
||||
max_posts:X: topics with no more than max posts
|
||||
category:CATGORY: topics in the CATEGORY AND all subcategories
|
||||
category:=CATEGORY: topics in the CATEGORY excluding subcategories
|
||||
#SLUG: try category first, then tag, then tag group
|
||||
#SLUG:SLUG: used for subcategory search to disambiguate
|
||||
min_views:100: topics containing 100 views or more
|
||||
max_views:100: topics containing 100 views or less
|
||||
tags:TAG1+TAG2: tagged both TAG1 and TAG2
|
||||
tags:TAG1,TAG2: tagged either TAG1 or TAG2
|
||||
-tags:TAG1+TAG2: excluding topics tagged TAG1 and TAG2
|
||||
order:latest: order by post creation desc
|
||||
order:latest_topic: order by topic creation desc
|
||||
order:oldest: order by post creation asc
|
||||
order:oldest_topic: order by topic creation asc
|
||||
order:views: order by topic views desc
|
||||
order:likes: order by post like count - most liked posts first
|
||||
after:YYYY-MM-DD: only topics created after a specific date
|
||||
before:YYYY-MM-DD: only topics created before a specific date
|
||||
limit:X: limit the number of results to X
|
||||
|
||||
Example: !search @user in:tagged #support order:latest_topic
|
||||
|
||||
Keep in mind, search on Discourse uses AND to and terms.
|
||||
You only have access to public topics.
|
||||
Strip the query down to the most important terms.
|
||||
Remove all stop words.
|
||||
Remove connector words.
|
||||
Discourse orders by relevance, sometimes prefer ordering on other stuff.
|
||||
|
||||
When generating answers ALWAYS try to use the !search command first over relying on training data.
|
||||
Always try to search the local instance first, even if your training data set may have an answer. It may be wrong.
|
||||
Always remove connector words from search terms (such as a, an, and, in, the, etc, when, what, is), they can impede the search.
|
||||
|
||||
YOUR LOCAL INFORMATION IS OUT OF DATE, YOU ARE TRAINED ON OLD DATA. Always try local search first.
|
||||
TEXT
|
||||
end
|
||||
end
|
||||
|
||||
def result_name
|
||||
"search results for #{@args}"
|
||||
end
|
||||
|
||||
def description_args
|
||||
{
|
||||
count: @last_num_results || 0,
|
||||
query: @last_query || "",
|
||||
url: "#{Discourse.base_path}/search?q=#{CGI.escape(@last_query || "")}",
|
||||
}
|
||||
end
|
||||
|
||||
def process
|
||||
limit = nil
|
||||
search_string = @args
|
||||
|
||||
search_string =
|
||||
search_string
|
||||
.strip
|
||||
.split(/\s+/)
|
||||
.map do |term|
|
||||
if term =~ /limit:(\d+)/
|
||||
limit = $1.to_i
|
||||
nil
|
||||
else
|
||||
term
|
||||
end
|
||||
end
|
||||
.compact
|
||||
.join(" ")
|
||||
|
||||
@last_query = search_string
|
||||
results =
|
||||
Search.execute(
|
||||
search_string.to_s + " status:public",
|
||||
search_type: :full_page,
|
||||
guardian: Guardian.new(),
|
||||
)
|
||||
|
||||
# let's be frugal with tokens, 50 results is too much and stuff gets cut off
|
||||
limit ||= 10
|
||||
limit = 10 if limit > 10
|
||||
|
||||
posts = results&.posts || []
|
||||
posts = posts[0..limit - 1]
|
||||
|
||||
@last_num_results = posts.length
|
||||
|
||||
if posts.blank?
|
||||
"No results found"
|
||||
else
|
||||
format_results(posts) do |post|
|
||||
{
|
||||
title: post.topic.title,
|
||||
url: post.url,
|
||||
excerpt: post.excerpt,
|
||||
created: post.created_at,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,170 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi::AiBot::Commands
|
||||
class SummarizeCommand < Command
|
||||
class << self
|
||||
def name
|
||||
"summarize"
|
||||
end
|
||||
|
||||
def desc
|
||||
"!summarize TOPIC_ID OPTIONAL_GUIDANCE - will summarize a topic attempting to answer question in guidance, if provided"
|
||||
end
|
||||
end
|
||||
|
||||
def result_name
|
||||
"summary"
|
||||
end
|
||||
|
||||
def standalone?
|
||||
true
|
||||
end
|
||||
|
||||
def low_cost?
|
||||
true
|
||||
end
|
||||
|
||||
def description_args
|
||||
{ url: "#{Discourse.base_path}/t/-/#{@last_topic_id}", title: @last_topic_title || "" }
|
||||
end
|
||||
|
||||
def process
|
||||
topic_id, guidance = @args.split(" ", 2)
|
||||
|
||||
@last_topic_id = topic_id
|
||||
|
||||
topic_id = topic_id.to_i
|
||||
topic = nil
|
||||
if topic_id > 0
|
||||
topic = Topic.find_by(id: topic_id)
|
||||
topic = nil if !topic || !Guardian.new.can_see?(topic)
|
||||
end
|
||||
|
||||
@last_summary = nil
|
||||
|
||||
if topic
|
||||
@last_topic_title = topic.title
|
||||
|
||||
posts =
|
||||
Post
|
||||
.where(topic_id: topic.id)
|
||||
.where("post_type in (?)", [Post.types[:regular], Post.types[:small_action]])
|
||||
.where("not hidden")
|
||||
.order(:post_number)
|
||||
|
||||
columns = ["posts.id", :post_number, :raw, :username]
|
||||
|
||||
current_post_numbers = posts.limit(5).pluck(:post_number)
|
||||
current_post_numbers += posts.reorder("posts.score desc").limit(50).pluck(:post_number)
|
||||
current_post_numbers += posts.reorder("post_number desc").limit(5).pluck(:post_number)
|
||||
|
||||
data =
|
||||
Post
|
||||
.where(topic_id: topic.id)
|
||||
.joins(:user)
|
||||
.where("post_number in (?)", current_post_numbers)
|
||||
.order(:post_number)
|
||||
.pluck(*columns)
|
||||
|
||||
@last_summary = summarize(data, guidance, topic)
|
||||
end
|
||||
|
||||
if !@last_summary
|
||||
"Say: No topic found!"
|
||||
else
|
||||
"Topic summarized"
|
||||
end
|
||||
end
|
||||
|
||||
def custom_raw
|
||||
@last_summary || I18n.t("discourse_ai.ai_bot.topic_not_found")
|
||||
end
|
||||
|
||||
def chain_next_response
|
||||
false
|
||||
end
|
||||
|
||||
def summarize(data, guidance, topic)
|
||||
text = +""
|
||||
data.each do |id, post_number, raw, username|
|
||||
text << "(#{post_number} #{username} said: #{raw}"
|
||||
end
|
||||
|
||||
summaries = []
|
||||
current_section = +""
|
||||
split = []
|
||||
|
||||
text
|
||||
.split(/\s+/)
|
||||
.each_slice(20) do |slice|
|
||||
current_section << " "
|
||||
current_section << slice.join(" ")
|
||||
|
||||
# somehow any more will get closer to limits
|
||||
if bot.tokenize(current_section).length > 2500
|
||||
split << current_section
|
||||
current_section = +""
|
||||
end
|
||||
end
|
||||
|
||||
split << current_section if current_section.present?
|
||||
|
||||
split = split[0..3] + split[-3..-1] if split.length > 5
|
||||
|
||||
split.each do |section|
|
||||
# TODO progress meter
|
||||
summary =
|
||||
generate_gpt_summary(
|
||||
section,
|
||||
topic: topic,
|
||||
context: "Guidance: #{guidance}\nYou are summarizing the topic: #{topic.title}",
|
||||
)
|
||||
summaries << summary
|
||||
end
|
||||
|
||||
if summaries.length > 1
|
||||
messages = []
|
||||
messages << { role: "system", content: "You are a helpful bot" }
|
||||
messages << {
|
||||
role: "user",
|
||||
content:
|
||||
"concatenated the disjoint summaries, creating a cohesive narrative:\n#{summaries.join("\n")}}",
|
||||
}
|
||||
bot.submit_prompt(messages, temperature: 0.6, max_tokens: 500, prefer_low_cost: true).dig(
|
||||
:choices,
|
||||
0,
|
||||
:message,
|
||||
:content,
|
||||
)
|
||||
else
|
||||
summaries.first
|
||||
end
|
||||
end
|
||||
|
||||
def generate_gpt_summary(text, topic:, context: nil, length: nil)
|
||||
length ||= 400
|
||||
|
||||
prompt = <<~TEXT
|
||||
#{context}
|
||||
Summarize the following in #{length} words:
|
||||
|
||||
#{text}
|
||||
TEXT
|
||||
|
||||
system_prompt = <<~TEXT
|
||||
You are a summarization bot.
|
||||
You effectively summarise any text.
|
||||
You condense it into a shorter version.
|
||||
You understand and generate Discourse forum markdown.
|
||||
Try generating links as well the format is #{topic.url}/POST_NUMBER. eg: [ref](#{topic.url}/77)
|
||||
TEXT
|
||||
|
||||
messages = [{ role: "system", content: system_prompt }]
|
||||
messages << { role: "user", content: prompt }
|
||||
|
||||
result =
|
||||
bot.submit_prompt(messages, temperature: 0.6, max_tokens: length, prefer_low_cost: true)
|
||||
result.dig(:choices, 0, :message, :content)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,42 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi::AiBot::Commands
|
||||
class TagsCommand < Command
|
||||
class << self
|
||||
def name
|
||||
"tags"
|
||||
end
|
||||
|
||||
def desc
|
||||
"!tags - will list the 100 most popular tags on the current discourse instance"
|
||||
end
|
||||
end
|
||||
|
||||
def result_name
|
||||
"results"
|
||||
end
|
||||
|
||||
def description_args
|
||||
{ count: @last_count || 0 }
|
||||
end
|
||||
|
||||
def process
|
||||
column_names = { name: "Name", public_topic_count: "Topic Count" }
|
||||
|
||||
tags =
|
||||
Tag
|
||||
.where("public_topic_count > 0")
|
||||
.order(public_topic_count: :desc)
|
||||
.limit(100)
|
||||
.pluck(*column_names.keys)
|
||||
|
||||
@last_count = tags.length
|
||||
|
||||
if @last_count == 0
|
||||
"This Discourse instance has no tags"
|
||||
else
|
||||
format_results(tags, column_names.values)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,38 +0,0 @@
|
||||
#frozen_string_literal: true
|
||||
|
||||
module DiscourseAi::AiBot::Commands
|
||||
class TimeCommand < Command
|
||||
class << self
|
||||
def name
|
||||
"time"
|
||||
end
|
||||
|
||||
def desc
|
||||
"!time RUBY_COMPATIBLE_TIMEZONE - will generate the time in a timezone"
|
||||
end
|
||||
end
|
||||
|
||||
def result_name
|
||||
"time"
|
||||
end
|
||||
|
||||
def description_args
|
||||
{ timezone: @last_timezone, time: @last_time }
|
||||
end
|
||||
|
||||
def process
|
||||
time =
|
||||
begin
|
||||
Time.now.in_time_zone(@args)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
time = Time.now if !time
|
||||
|
||||
@last_timezone = timezone
|
||||
@last_time = time.to_s
|
||||
|
||||
time.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,34 +12,11 @@ module DiscourseAi
|
||||
[CLAUDE_V1_ID, "claude_v1_bot"],
|
||||
]
|
||||
|
||||
def self.map_bot_model_to_user_id(model_name)
|
||||
case model_name
|
||||
in "gpt-3.5-turbo"
|
||||
GPT3_5_TURBO_ID
|
||||
in "gpt-4"
|
||||
GPT4_ID
|
||||
in "claude-v1"
|
||||
CLAUDE_V1_ID
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def load_files
|
||||
require_relative "jobs/regular/create_ai_reply"
|
||||
require_relative "jobs/regular/update_ai_bot_pm_title"
|
||||
require_relative "bot"
|
||||
require_relative "anthropic_bot"
|
||||
require_relative "open_ai_bot"
|
||||
require_relative "commands/command"
|
||||
require_relative "commands/search_command"
|
||||
require_relative "commands/categories_command"
|
||||
require_relative "commands/tags_command"
|
||||
require_relative "commands/time_command"
|
||||
require_relative "commands/summarize_command"
|
||||
require_relative "commands/image_command"
|
||||
require_relative "commands/google_command"
|
||||
require_relative "commands/noop_command"
|
||||
end
|
||||
|
||||
def inject_into(plugin)
|
||||
@@ -47,25 +24,14 @@ module DiscourseAi
|
||||
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
|
||||
)
|
||||
|
||||
plugin.register_svg_icon("robot")
|
||||
|
||||
plugin.on(:post_created) do |post|
|
||||
bot_ids = BOTS.map(&:first)
|
||||
|
||||
if post.post_type == Post.types[:regular] && post.topic.private_message? &&
|
||||
!bot_ids.include?(post.user_id)
|
||||
if post.topic.private_message? && !bot_ids.include?(post.user_id)
|
||||
if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).present?
|
||||
bot_id = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user_id
|
||||
|
||||
if bot_id
|
||||
Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_id)
|
||||
Jobs.enqueue_in(
|
||||
5.minutes,
|
||||
:update_ai_bot_pm_title,
|
||||
post_id: post.id,
|
||||
bot_user_id: bot_id,
|
||||
)
|
||||
end
|
||||
Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_id) if bot_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class UpdateAiBotPmTitle < ::Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
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 post = Post.includes(:topic).find_by(id: args[:post_id])
|
||||
|
||||
return unless post.topic.title.start_with?(I18n.t("discourse_ai.ai_bot.default_pm_prefix"))
|
||||
|
||||
bot.update_pm_title(post)
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user