Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655c7e9708 | ||
|
|
4fb686a548 | ||
|
|
5026ab52d0 | ||
|
|
823e8ef490 | ||
|
|
f75b13c4fa | ||
|
|
9551b1a4d1 | ||
|
|
aef9a03d4c | ||
|
|
9583964676 | ||
|
|
2fc05685bb | ||
|
|
26bfda4763 | ||
|
|
6ef635619f | ||
|
|
e817b7dc11 | ||
|
|
644141ff08 | ||
|
|
fbc74c7467 | ||
|
|
c421f713a3 | ||
|
|
021e09607d | ||
|
|
893fa624e4 | ||
|
|
1ad5321c09 | ||
|
|
da7c97d294 | ||
|
|
ab4678275d | ||
|
|
99282612a9 |
@@ -6,6 +6,14 @@ module DiscourseAi
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
requires_login
|
||||
|
||||
def show_debug_info_by_id
|
||||
log = AiApiAuditLog.find(params[:id])
|
||||
raise Discourse::NotFound if !log.topic
|
||||
|
||||
guardian.ensure_can_debug_ai_bot_conversation!(log.topic)
|
||||
render json: AiApiAuditLogSerializer.new(log, root: false), status: 200
|
||||
end
|
||||
|
||||
def show_debug_info
|
||||
post = Post.find(params[:post_id])
|
||||
guardian.ensure_can_debug_ai_bot_conversation!(post)
|
||||
|
||||
@@ -10,34 +10,48 @@ module ::Jobs
|
||||
return if !SiteSetting.ai_summarization_enabled
|
||||
return if SiteSetting.ai_summary_backfill_maximum_topics_per_hour.zero?
|
||||
|
||||
# Split budget in 12 intervals, but make sure is at least one.
|
||||
limit_per_job = [SiteSetting.ai_summary_backfill_maximum_topics_per_hour, 12].max / 12
|
||||
budget = [current_budget, limit_per_job].min
|
||||
system_user = Discourse.system_user
|
||||
|
||||
backfill_candidates
|
||||
.limit(budget)
|
||||
complete_t = AiSummary.summary_types[:complete]
|
||||
backfill_candidates(complete_t)
|
||||
.limit(current_budget(complete_t))
|
||||
.each do |topic|
|
||||
DiscourseAi::Summarization.topic_summary(topic).force_summarize(Discourse.system_user)
|
||||
DiscourseAi::Summarization.topic_summary(topic).force_summarize(system_user)
|
||||
end
|
||||
|
||||
gist_t = AiSummary.summary_types[:gist]
|
||||
backfill_candidates(gist_t)
|
||||
.limit(current_budget(gist_t))
|
||||
.each { |topic| DiscourseAi::Summarization.topic_gist(topic).force_summarize(system_user) }
|
||||
end
|
||||
|
||||
def backfill_candidates
|
||||
def backfill_candidates(summary_type)
|
||||
Topic
|
||||
.where("topics.word_count >= ?", SiteSetting.ai_summary_backfill_minimum_word_count)
|
||||
.joins(
|
||||
"LEFT OUTER JOIN ai_summaries ais ON topics.id = ais.target_id AND ais.target_type = 'Topic'",
|
||||
)
|
||||
.joins(<<~SQL)
|
||||
LEFT OUTER JOIN ai_summaries ais ON
|
||||
topics.id = ais.target_id AND
|
||||
ais.target_type = 'Topic' AND
|
||||
ais.summary_type = '#{summary_type}'
|
||||
SQL
|
||||
.where(
|
||||
"ais.id IS NULL OR UPPER(ais.content_range) < topics.highest_post_number + 1",
|
||||
) # (1..1) gets stored ad (1..2).
|
||||
.order("ais.created_at DESC NULLS FIRST, topics.last_posted_at DESC")
|
||||
end
|
||||
|
||||
def current_budget
|
||||
def current_budget(type)
|
||||
# Split budget in 12 intervals, but make sure is at least one.
|
||||
base_budget = SiteSetting.ai_summary_backfill_maximum_topics_per_hour
|
||||
used_budget = AiSummary.complete.system.where("created_at > ?", 1.hour.ago).count
|
||||
limit_per_job = [base_budget, 12].max / 12
|
||||
|
||||
base_budget - used_budget
|
||||
used_budget =
|
||||
AiSummary.system.where("created_at > ?", 1.hour.ago).where(summary_type: type).count
|
||||
|
||||
current_budget = [(base_budget - used_budget), limit_per_job].min
|
||||
return 0 if current_budget < 0
|
||||
|
||||
current_budget
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,6 +14,14 @@ class AiApiAuditLog < ActiveRecord::Base
|
||||
Ollama = 7
|
||||
SambaNova = 8
|
||||
end
|
||||
|
||||
def next_log_id
|
||||
self.class.where("id > ?", id).where(topic_id: topic_id).order(id: :asc).pluck(:id).first
|
||||
end
|
||||
|
||||
def prev_log_id
|
||||
self.class.where("id < ?", id).where(topic_id: topic_id).order(id: :desc).pluck(:id).first
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
|
||||
+10
-1
@@ -95,7 +95,16 @@ class LlmModel < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def seeded?
|
||||
id < 0
|
||||
id.present? && id < 0
|
||||
end
|
||||
|
||||
def api_key
|
||||
if seeded?
|
||||
env_key = "DISCOURSE_AI_SEEDED_LLM_API_KEY_#{id.abs}"
|
||||
ENV[env_key] || self[:api_key]
|
||||
else
|
||||
self[:api_key]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -12,5 +12,7 @@ class AiApiAuditLogSerializer < ApplicationSerializer
|
||||
:post_id,
|
||||
:feature_name,
|
||||
:language_model,
|
||||
:created_at
|
||||
:created_at,
|
||||
:prev_log_id,
|
||||
:next_log_id
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { service } from "@ember/service";
|
||||
import bodyClass from "discourse/helpers/body-class";
|
||||
|
||||
export default class AiTopicGist extends Component {
|
||||
@service router;
|
||||
@@ -21,6 +22,7 @@ export default class AiTopicGist extends Component {
|
||||
|
||||
<template>
|
||||
{{#if this.showGist}}
|
||||
{{bodyClass "--topic-list-with-gist"}}
|
||||
<div class="ai-topic-gist">
|
||||
<div class="ai-topic-gist__text">
|
||||
{{@topic.ai_topic_gist}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { htmlSafe } from "@ember/template";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DModal from "discourse/components/d-modal";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { clipboardCopy, escapeExpression } from "discourse/lib/utilities";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
@@ -63,6 +64,28 @@ export default class DebugAiModal extends Component {
|
||||
this.copy(this.info.raw_response_payload);
|
||||
}
|
||||
|
||||
async loadLog(logId) {
|
||||
try {
|
||||
await ajax(`/discourse-ai/ai-bot/show-debug-info/${logId}.json`).then(
|
||||
(result) => {
|
||||
this.info = result;
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
prevLog() {
|
||||
this.loadLog(this.info.prev_log_id);
|
||||
}
|
||||
|
||||
@action
|
||||
nextLog() {
|
||||
this.loadLog(this.info.next_log_id);
|
||||
}
|
||||
|
||||
copy(text) {
|
||||
clipboardCopy(text);
|
||||
this.justCopiedText = I18n.t("discourse_ai.ai_bot.conversation_shared");
|
||||
@@ -73,11 +96,13 @@ export default class DebugAiModal extends Component {
|
||||
}
|
||||
|
||||
loadApiRequestInfo() {
|
||||
ajax(
|
||||
`/discourse-ai/ai-bot/post/${this.args.model.id}/show-debug-info.json`
|
||||
).then((result) => {
|
||||
this.info = result;
|
||||
});
|
||||
ajax(`/discourse-ai/ai-bot/post/${this.args.model.id}/show-debug-info.json`)
|
||||
.then((result) => {
|
||||
this.info = result;
|
||||
})
|
||||
.catch((e) => {
|
||||
popupAjaxError(e);
|
||||
});
|
||||
}
|
||||
|
||||
get requestActive() {
|
||||
@@ -147,6 +172,22 @@ export default class DebugAiModal extends Component {
|
||||
@action={{this.copyResponse}}
|
||||
@label="discourse_ai.ai_bot.debug_ai_modal.copy_response"
|
||||
/>
|
||||
{{#if this.info.prev_log_id}}
|
||||
<DButton
|
||||
class="btn"
|
||||
@icon="angles-left"
|
||||
@action={{this.prevLog}}
|
||||
@label="discourse_ai.ai_bot.debug_ai_modal.previous_log"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.info.next_log_id}}
|
||||
<DButton
|
||||
class="btn"
|
||||
@icon="angles-right"
|
||||
@action={{this.nextLog}}
|
||||
@label="discourse_ai.ai_bot.debug_ai_modal.next_log"
|
||||
/>
|
||||
{{/if}}
|
||||
<span class="ai-debut-modal__just-copied">{{this.justCopiedText}}</span>
|
||||
</:footer>
|
||||
</DModal>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
|
||||
export default class AiCancelStreamingButton extends Component {
|
||||
// TODO (glimmer-post-menu): Remove this static function and move the code into the button action after the widget code is removed
|
||||
static async cancelStreaming(post) {
|
||||
try {
|
||||
await ajax(`/discourse-ai/ai-bot/post/${post.id}/stop-streaming`, {
|
||||
type: "POST",
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector(`#post_${post.post_number}`)
|
||||
.classList.remove("streaming");
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
cancelStreaming() {
|
||||
this.constructor.cancelStreaming(this.args.post);
|
||||
}
|
||||
|
||||
<template>
|
||||
<DButton
|
||||
class="post-action-menu__ai-cancel-streaming cancel-streaming"
|
||||
...attributes
|
||||
@action={{this.cancelStreaming}}
|
||||
@icon="pause"
|
||||
@title="discourse_ai.ai_bot.cancel_streaming"
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { isPostFromAiBot } from "../../lib/ai-bot-helper";
|
||||
import DebugAiModal from "../modal/debug-ai-modal";
|
||||
|
||||
export default class AiDebugButton extends Component {
|
||||
static shouldRender(args) {
|
||||
return isPostFromAiBot(args.post, args.state.currentUser);
|
||||
}
|
||||
|
||||
// TODO (glimmer-post-menu): Remove this static function and move the code into the button action after the widget code is removed
|
||||
static debugAiResponse(post, modal) {
|
||||
modal.show(DebugAiModal, { model: post });
|
||||
}
|
||||
|
||||
@service modal;
|
||||
|
||||
@action
|
||||
debugAiResponse() {
|
||||
this.constructor.debugAiResponse(this.args.post, this.modal);
|
||||
}
|
||||
|
||||
<template>
|
||||
<DButton
|
||||
class="post-action-menu__debug-ai"
|
||||
...attributes
|
||||
@action={{this.debugAiResponse}}
|
||||
@icon="info"
|
||||
@title="discourse_ai.ai_bot.debug_ai"
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { isPostFromAiBot } from "../../lib/ai-bot-helper";
|
||||
import copyConversation from "../../lib/copy-conversation";
|
||||
import ShareModal from "../modal/share-modal";
|
||||
|
||||
const AUTO_COPY_THRESHOLD = 4;
|
||||
|
||||
export default class AiDebugButton extends Component {
|
||||
static shouldRender(args) {
|
||||
return isPostFromAiBot(args.post, args.state.currentUser);
|
||||
}
|
||||
|
||||
// TODO (glimmer-post-menu): Remove this static function and move the code into the button action after the widget code is removed
|
||||
static async shareAiResponse(post, modal, showFeedback) {
|
||||
if (post.post_number <= AUTO_COPY_THRESHOLD) {
|
||||
await copyConversation(post.topic, 1, post.post_number);
|
||||
showFeedback("discourse_ai.ai_bot.conversation_shared");
|
||||
} else {
|
||||
modal.show(ShareModal, { model: post });
|
||||
}
|
||||
}
|
||||
|
||||
@service modal;
|
||||
|
||||
@action
|
||||
shareAiResponse() {
|
||||
this.constructor.shareAiResponse(
|
||||
this.args.post,
|
||||
this.modal,
|
||||
this.args.showFeedback
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
<DButton
|
||||
class="post-action-menu__share-ai"
|
||||
...attributes
|
||||
@action={{this.shareAiResponse}}
|
||||
@icon="far-copy"
|
||||
@title="discourse_ai.ai_bot.share"
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export default class RagUploader extends Component {
|
||||
uppyUpload = new UppyUpload(getOwner(this), {
|
||||
id: "discourse-ai-rag-uploader",
|
||||
maxFiles: 20,
|
||||
type: "discourse_ai_rag_upload",
|
||||
uploadUrl:
|
||||
"/admin/plugins/discourse-ai/rag-document-fragments/files/upload",
|
||||
preventDirectS3Uploads: true,
|
||||
|
||||
+45
-30
@@ -18,19 +18,20 @@ export default class SemanticSearch extends Component {
|
||||
return siteSettings.ai_embeddings_semantic_search_enabled;
|
||||
}
|
||||
|
||||
@service router;
|
||||
@service appEvents;
|
||||
@service siteSettings;
|
||||
@service searchPreferencesManager;
|
||||
|
||||
@tracked searching = false;
|
||||
@tracked AIResults = [];
|
||||
@tracked showingAIResults = false;
|
||||
@tracked AiResults = [];
|
||||
@tracked showingAiResults = false;
|
||||
initialSearchTerm = this.args.outletArgs.search;
|
||||
|
||||
get disableToggleSwitch() {
|
||||
if (
|
||||
this.searching ||
|
||||
this.AIResults.length === 0 ||
|
||||
this.AiResults.length === 0 ||
|
||||
this.args.outletArgs.sortOrder !== 0
|
||||
) {
|
||||
return true;
|
||||
@@ -39,19 +40,19 @@ export default class SemanticSearch extends Component {
|
||||
|
||||
get searchStateText() {
|
||||
// Search results:
|
||||
if (this.AIResults.length > 0) {
|
||||
if (this.showingAIResults) {
|
||||
if (this.AiResults.length > 0) {
|
||||
if (this.showingAiResults) {
|
||||
return I18n.t(
|
||||
"discourse_ai.embeddings.semantic_search_results.toggle",
|
||||
{
|
||||
count: this.AIResults.length,
|
||||
count: this.AiResults.length,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return I18n.t(
|
||||
"discourse_ai.embeddings.semantic_search_results.toggle_hidden",
|
||||
{
|
||||
count: this.AIResults.length,
|
||||
count: this.AiResults.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -64,14 +65,14 @@ export default class SemanticSearch extends Component {
|
||||
|
||||
// Typing to search:
|
||||
if (
|
||||
this.AIResults.length === 0 &&
|
||||
this.AiResults.length === 0 &&
|
||||
this.searchTerm !== this.initialSearchTerm
|
||||
) {
|
||||
return I18n.t("discourse_ai.embeddings.semantic_search_results.new");
|
||||
}
|
||||
|
||||
// No results:
|
||||
if (this.AIResults.length === 0) {
|
||||
if (this.AiResults.length === 0) {
|
||||
return I18n.t("discourse_ai.embeddings.semantic_search_results.none");
|
||||
}
|
||||
}
|
||||
@@ -93,19 +94,19 @@ export default class SemanticSearch extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
toggleAIResults() {
|
||||
if (this.showingAIResults) {
|
||||
toggleAiResults() {
|
||||
if (this.showingAiResults) {
|
||||
this.args.outletArgs.addSearchResults([], "topic_id");
|
||||
} else {
|
||||
this.args.outletArgs.addSearchResults(this.AIResults, "topic_id");
|
||||
this.args.outletArgs.addSearchResults(this.AiResults, "topic_id");
|
||||
}
|
||||
this.showingAIResults = !this.showingAIResults;
|
||||
this.showingAiResults = !this.showingAiResults;
|
||||
}
|
||||
|
||||
@action
|
||||
resetAIResults() {
|
||||
this.AIResults = [];
|
||||
this.showingAIResults = false;
|
||||
resetAiResults() {
|
||||
this.AiResults = [];
|
||||
this.showingAiResults = false;
|
||||
this.args.outletArgs.addSearchResults([], "topic_id");
|
||||
}
|
||||
|
||||
@@ -119,19 +120,12 @@ export default class SemanticSearch extends Component {
|
||||
return this.performHyDESearch();
|
||||
}
|
||||
|
||||
withPluginApi("1.15.0", (api) => {
|
||||
api.onAppEvent("full-page-search:trigger-search", () => {
|
||||
if (!this.searching) {
|
||||
this.resetAIResults();
|
||||
return this.performHyDESearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
this.#resetAndSearchOnEvent();
|
||||
}
|
||||
|
||||
performHyDESearch() {
|
||||
this.searching = true;
|
||||
this.resetAIResults();
|
||||
this.resetAiResults();
|
||||
|
||||
ajax("/discourse-ai/embeddings/semantic-search", {
|
||||
data: { q: this.searchTerm },
|
||||
@@ -145,15 +139,37 @@ export default class SemanticSearch extends Component {
|
||||
}
|
||||
|
||||
model.posts.forEach((post) => {
|
||||
post.generatedByAI = true;
|
||||
post.generatedByAi = true;
|
||||
});
|
||||
|
||||
this.AIResults = model.posts;
|
||||
this.AiResults = model.posts;
|
||||
})
|
||||
.finally(() => (this.searching = false));
|
||||
}
|
||||
|
||||
#resetAndSearchOnEvent() {
|
||||
return withPluginApi("1.15.0", (api) => {
|
||||
api.onAppEvent("full-page-search:trigger-search", () => {
|
||||
if (!this.searching) {
|
||||
this.resetAiResults();
|
||||
return this.performHyDESearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
checkQueryParamsAndSearch() {
|
||||
// This check is necessary because handleSearch() isn't called
|
||||
// if query params are present and a new search has appended text.
|
||||
// It ensures AiResults are reset and searched for properly
|
||||
const searchQueryParam = this.router.currentRoute?.queryParams?.q;
|
||||
if (searchQueryParam) {
|
||||
this.#resetAndSearchOnEvent();
|
||||
}
|
||||
}
|
||||
<template>
|
||||
<span {{didInsert this.checkQueryParamsAndSearch}}></span>
|
||||
{{#if this.searchEnabled}}
|
||||
<div class="semantic-search__container search-results" role="region">
|
||||
<div class="semantic-search__results" {{didInsert this.handleSearch}}>
|
||||
@@ -163,10 +179,9 @@ export default class SemanticSearch extends Component {
|
||||
>
|
||||
<DToggleSwitch
|
||||
disabled={{this.disableToggleSwitch}}
|
||||
@state={{this.showingAIResults}}
|
||||
title="AI search results hidden"
|
||||
@state={{this.showingAiResults}}
|
||||
class="semantic-search__results-toggle"
|
||||
{{on "click" this.toggleAIResults}}
|
||||
{{on "click" this.toggleAiResults}}
|
||||
/>
|
||||
|
||||
<div class="semantic-search__searching-text">
|
||||
|
||||
+50
-17
@@ -5,8 +5,9 @@ import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import { next } from "@ember/runloop";
|
||||
import { cancel, later } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import CookText from "discourse/components/cook-text";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
@@ -18,8 +19,8 @@ import I18n from "discourse-i18n";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
|
||||
import streamUpdaterText from "../../lib/ai-streamer/progress-handlers";
|
||||
import SummaryUpdater from "../../lib/ai-streamer/updaters/summary-updater";
|
||||
|
||||
const STREAMED_TEXT_SPEED = 15;
|
||||
|
||||
export default class AiSummaryBox extends Component {
|
||||
@service siteSettings;
|
||||
@@ -35,8 +36,10 @@ export default class AiSummaryBox extends Component {
|
||||
@tracked canRegenerate = false;
|
||||
@tracked loading = false;
|
||||
@tracked isStreaming = false;
|
||||
oldRaw = null; // used for comparison in SummaryUpdater in lib/ai-streamer/updaters
|
||||
finalSummary = null;
|
||||
@tracked streamedText = "";
|
||||
@tracked currentIndex = 0;
|
||||
typingTimer = null;
|
||||
streamedTextLength = 0;
|
||||
|
||||
get outdatedSummaryWarningText() {
|
||||
let outdatedText = I18n.t("summary.outdated");
|
||||
@@ -52,6 +55,8 @@ export default class AiSummaryBox extends Component {
|
||||
}
|
||||
|
||||
resetSummary() {
|
||||
this.streamedText = "";
|
||||
this.currentIndex = 0;
|
||||
this.text = "";
|
||||
this.summarizedOn = null;
|
||||
this.summarizedBy = null;
|
||||
@@ -83,8 +88,7 @@ export default class AiSummaryBox extends Component {
|
||||
}
|
||||
const channel = `/discourse-ai/summaries/topic/${this.args.outletArgs.topic.id}`;
|
||||
this._channel = channel;
|
||||
// we attempt to recover the last message in the bus so we subscrcibe at -2
|
||||
this.messageBus.subscribe(channel, this._updateSummary, -2);
|
||||
this.messageBus.subscribe(channel, this._updateSummary);
|
||||
}
|
||||
|
||||
@bind
|
||||
@@ -119,6 +123,8 @@ export default class AiSummaryBox extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// ensure summary is reset before requesting a new one:
|
||||
this.resetSummary();
|
||||
return this._requestSummary(fetchURL);
|
||||
}
|
||||
|
||||
@@ -134,36 +140,63 @@ export default class AiSummaryBox extends Component {
|
||||
return ajax(url).then((data) => {
|
||||
if (data?.ai_topic_summary?.summarized_text) {
|
||||
data.done = true;
|
||||
// Our streamer won't display the summary unless the summary box is in the DOM.
|
||||
// Wait for the next runloop or cached summaries won't appear.
|
||||
next(() => this._updateSummary(data));
|
||||
this._updateSummary(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
typeCharacter() {
|
||||
if (this.streamedTextLength < this.text.length) {
|
||||
this.streamedText += this.text.charAt(this.streamedTextLength);
|
||||
this.streamedTextLength++;
|
||||
|
||||
this.typingTimer = later(this, this.typeCharacter, STREAMED_TEXT_SPEED);
|
||||
} else {
|
||||
this.typingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onTextUpdate() {
|
||||
// Reset only if there’s a new summary to process
|
||||
if (this.typingTimer) {
|
||||
cancel(this.typingTimer);
|
||||
}
|
||||
|
||||
this.typeCharacter();
|
||||
}
|
||||
|
||||
@bind
|
||||
_updateSummary(update) {
|
||||
async _updateSummary(update) {
|
||||
const topicSummary = {
|
||||
done: update.done,
|
||||
raw: update.ai_topic_summary?.summarized_text,
|
||||
...update.ai_topic_summary,
|
||||
};
|
||||
const newText = topicSummary.raw || "";
|
||||
this.loading = false;
|
||||
|
||||
this.isStreaming = true;
|
||||
streamUpdaterText(SummaryUpdater, topicSummary, this);
|
||||
|
||||
if (update.done) {
|
||||
this.text = newText;
|
||||
this.streamedText = newText;
|
||||
this.displayedTextLength = newText.length;
|
||||
this.isStreaming = false;
|
||||
this.text = this.finalSummary;
|
||||
this.summarizedOn = shortDateNoYear(
|
||||
moment(topicSummary.updated_at, "YYYY-MM-DD HH:mm:ss Z")
|
||||
);
|
||||
this.summarizedBy = topicSummary.algorithm;
|
||||
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
|
||||
this.outdated = topicSummary.outdated;
|
||||
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
|
||||
this.canRegenerate = topicSummary.outdated && topicSummary.can_regenerate;
|
||||
|
||||
// Clear pending animations
|
||||
if (this.typingTimer) {
|
||||
cancel(this.typingTimer);
|
||||
this.typingTimer = null;
|
||||
}
|
||||
} else if (newText.length > this.text.length) {
|
||||
this.text = newText;
|
||||
this.isStreaming = true;
|
||||
this.onTextUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +261,7 @@ export default class AiSummaryBox extends Component {
|
||||
<AiSummarySkeleton />
|
||||
{{else}}
|
||||
<div class="generated-summary cooked">
|
||||
{{this.text}}
|
||||
<CookText @rawText={{this.streamedText}} />
|
||||
</div>
|
||||
{{#if this.summarizedOn}}
|
||||
<div class="summarized-on">
|
||||
|
||||
@@ -4,6 +4,17 @@ import Composer from "discourse/models/composer";
|
||||
import I18n from "I18n";
|
||||
import ShareFullTopicModal from "../components/modal/share-full-topic-modal";
|
||||
|
||||
const MAX_PERSONA_USER_ID = -1200;
|
||||
|
||||
export function isPostFromAiBot(post, currentUser) {
|
||||
return (
|
||||
post.user_id <= MAX_PERSONA_USER_ID ||
|
||||
!!currentUser?.ai_enabled_chat_bots?.any(
|
||||
(bot) => post.username === bot.username
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function showShareConversationModal(modal, topicId) {
|
||||
ajax(`/discourse-ai/ai-bot/shared-ai-conversations/preview/${topicId}.json`)
|
||||
.then((payload) => {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { cook } from "discourse/lib/text";
|
||||
import StreamUpdater from "./stream-updater";
|
||||
|
||||
export default class SummaryUpdater extends StreamUpdater {
|
||||
constructor(topicSummary, componentContext) {
|
||||
super();
|
||||
this.topicSummary = topicSummary;
|
||||
this.componentContext = componentContext;
|
||||
|
||||
if (this.topicSummary) {
|
||||
this.summaryBox = document.querySelector("article.ai-summary-box");
|
||||
}
|
||||
}
|
||||
|
||||
get element() {
|
||||
return this.summaryBox;
|
||||
}
|
||||
|
||||
set streaming(value) {
|
||||
if (this.element) {
|
||||
if (value) {
|
||||
this.componentContext.isStreaming = true;
|
||||
} else {
|
||||
this.componentContext.isStreaming = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setRaw(value, done) {
|
||||
this.componentContext.oldRaw = value;
|
||||
const cooked = await cook(value);
|
||||
|
||||
await this.setCooked(cooked);
|
||||
|
||||
if (done) {
|
||||
this.componentContext.finalSummary = cooked;
|
||||
}
|
||||
}
|
||||
|
||||
async setCooked(value) {
|
||||
this.componentContext.text = value;
|
||||
}
|
||||
|
||||
get raw() {
|
||||
return this.componentContext.oldRaw || "";
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
|
||||
import DebugAiModal from "../discourse/components/modal/debug-ai-modal";
|
||||
import ShareModal from "../discourse/components/modal/share-modal";
|
||||
import { streamPostText } from "../discourse/lib/ai-streamer/progress-handlers";
|
||||
import copyConversation from "../discourse/lib/copy-conversation";
|
||||
const AUTO_COPY_THRESHOLD = 4;
|
||||
import { withSilencedDeprecations } from "discourse-common/lib/deprecated";
|
||||
import AiBotHeaderIcon from "../discourse/components/ai-bot-header-icon";
|
||||
import { showShareConversationModal } from "../discourse/lib/ai-bot-helper";
|
||||
import AiCancelStreamingButton from "../discourse/components/post-menu/ai-cancel-streaming-button";
|
||||
import AiDebugButton from "../discourse/components/post-menu/ai-debug-button";
|
||||
import AiShareButton from "../discourse/components/post-menu/ai-share-button";
|
||||
import {
|
||||
isPostFromAiBot,
|
||||
showShareConversationModal,
|
||||
} from "../discourse/lib/ai-bot-helper";
|
||||
import { streamPostText } from "../discourse/lib/ai-streamer/progress-handlers";
|
||||
|
||||
let enabledChatBotIds = [];
|
||||
let allowDebug = false;
|
||||
|
||||
function isGPTBot(user) {
|
||||
return user && enabledChatBotIds.includes(user.id);
|
||||
}
|
||||
@@ -22,29 +24,7 @@ function attachHeaderIcon(api) {
|
||||
}
|
||||
|
||||
function initializeAIBotReplies(api) {
|
||||
api.addPostMenuButton("cancel-gpt", (post) => {
|
||||
if (isGPTBot(post.user)) {
|
||||
return {
|
||||
icon: "pause",
|
||||
action: "cancelStreaming",
|
||||
title: "discourse_ai.ai_bot.cancel_streaming",
|
||||
className: "btn btn-default cancel-streaming",
|
||||
position: "first",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
api.attachWidgetAction("post", "cancelStreaming", function () {
|
||||
ajax(`/discourse-ai/ai-bot/post/${this.model.id}/stop-streaming`, {
|
||||
type: "POST",
|
||||
})
|
||||
.then(() => {
|
||||
document
|
||||
.querySelector(`#post_${this.model.post_number}`)
|
||||
.classList.remove("streaming");
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
});
|
||||
initializePauseButton(api);
|
||||
|
||||
api.modifyClass("controller:topic", {
|
||||
pluginId: "discourse-ai",
|
||||
@@ -102,7 +82,42 @@ function initializePersonaDecorator(api) {
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_PERSONA_USER_ID = -1200;
|
||||
function initializePauseButton(api) {
|
||||
const transformerRegistered = api.registerValueTransformer(
|
||||
"post-menu-buttons",
|
||||
({ value: dag, context: { post, firstButtonKey } }) => {
|
||||
if (isGPTBot(post.user)) {
|
||||
dag.add("ai-cancel-gpt", AiCancelStreamingButton, {
|
||||
before: firstButtonKey,
|
||||
after: ["ai-share", "ai-debug"],
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const silencedKey =
|
||||
transformerRegistered && "discourse.post-menu-widget-overrides";
|
||||
|
||||
withSilencedDeprecations(silencedKey, () => initializePauseWidgetButton(api));
|
||||
}
|
||||
|
||||
function initializePauseWidgetButton(api) {
|
||||
api.addPostMenuButton("cancel-gpt", (post) => {
|
||||
if (isGPTBot(post.user)) {
|
||||
return {
|
||||
icon: "pause",
|
||||
action: "cancelStreaming",
|
||||
title: "discourse_ai.ai_bot.cancel_streaming",
|
||||
className: "btn btn-default cancel-streaming",
|
||||
position: "first",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
api.attachWidgetAction("post", "cancelStreaming", function () {
|
||||
AiCancelStreamingButton.cancelStreaming(this.model);
|
||||
});
|
||||
}
|
||||
|
||||
function initializeDebugButton(api) {
|
||||
const currentUser = api.getCurrentUser();
|
||||
@@ -110,10 +125,30 @@ function initializeDebugButton(api) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transformerRegistered = api.registerValueTransformer(
|
||||
"post-menu-buttons",
|
||||
({ value: dag, context: { post, firstButtonKey } }) => {
|
||||
if (post.topic?.archetype === "private_message") {
|
||||
dag.add("ai-debug", AiDebugButton, {
|
||||
before: firstButtonKey,
|
||||
after: "ai-share",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const silencedKey =
|
||||
transformerRegistered && "discourse.post-menu-widget-overrides";
|
||||
|
||||
withSilencedDeprecations(silencedKey, () => initializeDebugWidgetButton(api));
|
||||
}
|
||||
|
||||
function initializeDebugWidgetButton(api) {
|
||||
const currentUser = api.getCurrentUser();
|
||||
|
||||
let debugAiResponse = async function ({ post }) {
|
||||
const modal = api.container.lookup("service:modal");
|
||||
|
||||
modal.show(DebugAiModal, { model: post });
|
||||
AiDebugButton.debugAiResponse(post, modal);
|
||||
};
|
||||
|
||||
api.addPostMenuButton("debugAi", (post) => {
|
||||
@@ -121,15 +156,8 @@ function initializeDebugButton(api) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!currentUser.ai_enabled_chat_bots.any(
|
||||
(bot) => post.username === bot.username
|
||||
)
|
||||
) {
|
||||
// special handling for personas (persona bot users start at ID -1200 and go down)
|
||||
if (post.user_id > MAX_PERSONA_USER_ID) {
|
||||
return;
|
||||
}
|
||||
if (!isPostFromAiBot(post, currentUser)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -148,14 +176,29 @@ function initializeShareButton(api) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shareAiResponse = async function ({ post, showFeedback }) {
|
||||
if (post.post_number <= AUTO_COPY_THRESHOLD) {
|
||||
await copyConversation(post.topic, 1, post.post_number);
|
||||
showFeedback("discourse_ai.ai_bot.conversation_shared");
|
||||
} else {
|
||||
const modal = api.container.lookup("service:modal");
|
||||
modal.show(ShareModal, { model: post });
|
||||
const transformerRegistered = api.registerValueTransformer(
|
||||
"post-menu-buttons",
|
||||
({ value: dag, context: { post, firstButtonKey } }) => {
|
||||
if (post.topic?.archetype === "private_message") {
|
||||
dag.add("ai-share", AiShareButton, {
|
||||
before: firstButtonKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const silencedKey =
|
||||
transformerRegistered && "discourse.post-menu-widget-overrides";
|
||||
|
||||
withSilencedDeprecations(silencedKey, () => initializeShareWidgetButton(api));
|
||||
}
|
||||
|
||||
function initializeShareWidgetButton(api) {
|
||||
const currentUser = api.getCurrentUser();
|
||||
|
||||
let shareAiResponse = async function ({ post, showFeedback }) {
|
||||
const modal = api.container.lookup("service:modal");
|
||||
AiShareButton.shareAiResponse(post, modal, showFeedback);
|
||||
};
|
||||
|
||||
api.addPostMenuButton("share", (post) => {
|
||||
@@ -164,21 +207,14 @@ function initializeShareButton(api) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!currentUser.ai_enabled_chat_bots.any(
|
||||
(bot) => post.username === bot.username
|
||||
)
|
||||
) {
|
||||
// special handling for personas (persona bot users start at ID -1200 and go down)
|
||||
if (post.user_id > MAX_PERSONA_USER_ID) {
|
||||
return;
|
||||
}
|
||||
if (!isPostFromAiBot(post, currentUser)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
action: shareAiResponse,
|
||||
icon: "far-copy",
|
||||
className: "post-action-menu__share",
|
||||
className: "post-action-menu__share-ai",
|
||||
title: "discourse_ai.ai_bot.share",
|
||||
position: "first",
|
||||
};
|
||||
@@ -218,10 +254,10 @@ export default {
|
||||
enabledChatBotIds = user.ai_enabled_chat_bots.map((bot) => bot.id);
|
||||
allowDebug = user.can_debug_ai_bot_conversations;
|
||||
withPluginApi("1.6.0", attachHeaderIcon);
|
||||
withPluginApi("1.6.0", initializeAIBotReplies);
|
||||
withPluginApi("1.34.0", initializeAIBotReplies);
|
||||
withPluginApi("1.6.0", initializePersonaDecorator);
|
||||
withPluginApi("1.22.0", (api) => initializeDebugButton(api, container));
|
||||
withPluginApi("1.22.0", (api) => initializeShareButton(api, container));
|
||||
withPluginApi("1.34.0", (api) => initializeDebugButton(api, container));
|
||||
withPluginApi("1.34.0", (api) => initializeShareButton(api, container));
|
||||
withPluginApi("1.22.0", (api) =>
|
||||
initializeShareTopicButton(api, container)
|
||||
);
|
||||
|
||||
@@ -4,6 +4,6 @@ export default apiInitializer("1.15.0", (api) => {
|
||||
api.modifyClass("component:search-result-entry", {
|
||||
pluginId: "discourse-ai",
|
||||
|
||||
classNameBindings: ["bulkSelectEnabled", "post.generatedByAI:ai-result"],
|
||||
classNameBindings: ["bulkSelectEnabled", "post.generatedByAi:ai-result"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cached, tracked } from "@glimmer/tracking";
|
||||
import BasicTopicList from "discourse/components/basic-topic-list";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
|
||||
const RelatedTopics = <template>
|
||||
@@ -11,7 +12,7 @@ const RelatedTopics = <template>
|
||||
class="more-topics__list"
|
||||
>
|
||||
<h3 id="related-topics-title" class="more-topics__list-title">
|
||||
{{i18n "discourse_ai.related_topics.title"}}
|
||||
{{icon "discourse-sparkles"}}{{i18n "discourse_ai.related_topics.title"}}
|
||||
</h3>
|
||||
<div class="topics">
|
||||
<BasicTopicList @topics={{@topic.relatedTopics}} />
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
.related-topics {
|
||||
margin: 4.5em 0 1em;
|
||||
}
|
||||
|
||||
.more-topics__container {
|
||||
h3 .d-icon {
|
||||
margin-right: 0.25em;
|
||||
color: var(--primary-high);
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
|
||||
.nav-pills .d-icon {
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,22 +224,58 @@
|
||||
line-height: 1.4;
|
||||
width: 90%;
|
||||
}
|
||||
.desktop-view & {
|
||||
margin-top: 0.15em;
|
||||
margin-bottom: -0.15em;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: var(--primary-high);
|
||||
text-wrap: pretty;
|
||||
word-wrap: break-word;
|
||||
font-size: var(--font-down-1);
|
||||
max-width: 70ch;
|
||||
max-width: 65ch;
|
||||
.visited & {
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.--topic-list-with-gist {
|
||||
.topic-list-item {
|
||||
.main-link {
|
||||
line-height: var(--line-height-medium);
|
||||
}
|
||||
.link-bottom-line {
|
||||
font-size: var(--font-down-1);
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.ai-topic-gist {
|
||||
font-size: var(--font-up-1);
|
||||
line-height: var(--line-height-large);
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.15em;
|
||||
}
|
||||
.topic-post-badges {
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-view & {
|
||||
.topic-list {
|
||||
.topic-list-item > .topic-list-data {
|
||||
padding: 1em 0;
|
||||
}
|
||||
.topic-item-stats .num.activity {
|
||||
align-self: end;
|
||||
margin-bottom: -0.15em; // vertical alignment
|
||||
}
|
||||
.pull-left {
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
.right {
|
||||
margin-left: 3.75em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-gists-dropdown-trigger {
|
||||
font-size: var(--font-down-1);
|
||||
color: var(--primary-medium);
|
||||
|
||||
@@ -333,6 +333,8 @@ ar:
|
||||
response_tokens: "رموز الرد:"
|
||||
request: "طلب"
|
||||
response: "الرد"
|
||||
next_log: "التالي"
|
||||
previous_log: "السابق"
|
||||
share_full_topic_modal:
|
||||
title: "مشاركة المحادثة بشكلٍ علني"
|
||||
share: "مشاركة ونسخ الرابط"
|
||||
|
||||
@@ -77,5 +77,6 @@ be:
|
||||
ai_bot:
|
||||
debug_ai_modal:
|
||||
request: "запыт"
|
||||
next_log: "Далей"
|
||||
share_modal:
|
||||
copy: "капіяваць"
|
||||
|
||||
@@ -80,6 +80,8 @@ bg:
|
||||
ai_bot:
|
||||
debug_ai_modal:
|
||||
request: "Заявка"
|
||||
next_log: "Напред"
|
||||
previous_log: "Предишни"
|
||||
share_modal:
|
||||
copy: "Копирай"
|
||||
summarization:
|
||||
|
||||
@@ -88,5 +88,7 @@ bs_BA:
|
||||
debug_ai_modal:
|
||||
request: "Zatraži"
|
||||
response: "Odgovor"
|
||||
next_log: "Iduće"
|
||||
previous_log: "Previous"
|
||||
share_modal:
|
||||
copy: "Copy"
|
||||
|
||||
@@ -89,5 +89,7 @@ ca:
|
||||
debug_ai_modal:
|
||||
request: "Sol·licita"
|
||||
response: "Reacció"
|
||||
next_log: "Següent"
|
||||
previous_log: "Previ"
|
||||
share_modal:
|
||||
copy: "Còpia"
|
||||
|
||||
@@ -92,6 +92,8 @@ cs:
|
||||
debug_ai_modal:
|
||||
request: "Požadavek"
|
||||
response: "Odpověď"
|
||||
next_log: "Další"
|
||||
previous_log: "Předchozí"
|
||||
share_modal:
|
||||
copy: "Kopírovat"
|
||||
summarization:
|
||||
|
||||
@@ -91,5 +91,7 @@ da:
|
||||
debug_ai_modal:
|
||||
request: "Anmod om medlemskab"
|
||||
response: "Svar"
|
||||
next_log: "Næste"
|
||||
previous_log: "Forrige"
|
||||
share_modal:
|
||||
copy: "Kopier"
|
||||
|
||||
@@ -11,7 +11,8 @@ de:
|
||||
scopes:
|
||||
descriptions:
|
||||
discourse_ai:
|
||||
search: "Ermöglicht die semantische Suche über den Endpunkt /discourse-ai/embeddings/semantic-search."
|
||||
search: "Ermöglicht semantische Suche"
|
||||
stream_completion: "Ermöglicht das Streamen von KI-Persona-Vervollständigungen"
|
||||
site_settings:
|
||||
categories:
|
||||
discourse_ai: "Discourse-KI"
|
||||
@@ -403,6 +404,8 @@ de:
|
||||
response_tokens: "Antwort-Token:"
|
||||
request: "Anfrage"
|
||||
response: "Antwort"
|
||||
next_log: "Nächstes"
|
||||
previous_log: "Vorheriges"
|
||||
share_full_topic_modal:
|
||||
title: "Unterhaltung öffentlich teilen"
|
||||
share: "Link teilen und kopieren"
|
||||
@@ -448,7 +451,9 @@ de:
|
||||
topic:
|
||||
title: "Zusammenfassung des Themas"
|
||||
close: "Zusammenfassungspanel schließen"
|
||||
disclosure: "Von KI erstellte Zusammenfassungen"
|
||||
gists_enabled: "mit Zusammenfassung"
|
||||
gists_enabled_long: "mit KI-generierter Zusammenfassung"
|
||||
gists_disabled: "ohne Zusammenfassung"
|
||||
review:
|
||||
types:
|
||||
reviewable_ai_post:
|
||||
|
||||
@@ -88,5 +88,7 @@ el:
|
||||
debug_ai_modal:
|
||||
request: "Αίτημα"
|
||||
response: "Απάντηση"
|
||||
next_log: "Επόμενο"
|
||||
previous_log: "Προηγούμενο"
|
||||
share_modal:
|
||||
copy: "Αντιγραφή"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user