Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ce8d943a7 | ||
|
|
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,
|
||||
|
||||
+20
-18
@@ -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,7 @@ 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";
|
||||
import smoothStreamText from "../../modifiers/smooth-stream-text";
|
||||
|
||||
export default class AiSummaryBox extends Component {
|
||||
@service siteSettings;
|
||||
@@ -35,8 +35,7 @@ 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 haltAnimation = false;
|
||||
|
||||
get outdatedSummaryWarningText() {
|
||||
let outdatedText = I18n.t("summary.outdated");
|
||||
@@ -83,8 +82,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 +117,8 @@ export default class AiSummaryBox extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// ensure summary is reset before requesting a new one:
|
||||
this.resetSummary();
|
||||
return this._requestSummary(fetchURL);
|
||||
}
|
||||
|
||||
@@ -134,35 +134,32 @@ 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@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.text = newText;
|
||||
this.loading = false;
|
||||
|
||||
this.isStreaming = true;
|
||||
streamUpdaterText(SummaryUpdater, topicSummary, this);
|
||||
|
||||
if (update.done) {
|
||||
this.text = newText;
|
||||
this.isStreaming = false;
|
||||
this.text = this.finalSummary;
|
||||
this.haltAnimation = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -227,8 +224,13 @@ export default class AiSummaryBox extends Component {
|
||||
{{#if this.loading}}
|
||||
<AiSummarySkeleton />
|
||||
{{else}}
|
||||
<div class="generated-summary cooked">
|
||||
{{this.text}}
|
||||
<div
|
||||
class="generated-summary"
|
||||
{{smoothStreamText this.text this.haltAnimation}}
|
||||
>
|
||||
{{#if this.haltAnimation}}
|
||||
<CookText @rawText={{this.text}} />
|
||||
{{/if}}
|
||||
</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) => {
|
||||
|
||||
@@ -113,10 +113,6 @@ export async function applyProgress(status, updater) {
|
||||
if (status.cooked) {
|
||||
await updater.setCooked(status.cooked);
|
||||
}
|
||||
// Ensure the final summary is set
|
||||
if (status.raw !== undefined) {
|
||||
await updater.setRaw(status.raw, true);
|
||||
}
|
||||
updater.streaming = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cancel, later } from "@ember/runloop";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import Modifier from "ember-modifier";
|
||||
import { cook } from "discourse/lib/text";
|
||||
|
||||
const STREAMED_TEXT_SPEED = 15;
|
||||
|
||||
export default class SmoothStreamTextModifier extends Modifier {
|
||||
typingTimer = null;
|
||||
displayedText = "";
|
||||
|
||||
modify(element, [text, haltAnimation]) {
|
||||
if (haltAnimation) {
|
||||
return;
|
||||
}
|
||||
this._startTypingAnimation(element, text);
|
||||
}
|
||||
|
||||
async _startTypingAnimation(element, text) {
|
||||
if (this.typingTimer) {
|
||||
cancel(this.typingTimer);
|
||||
}
|
||||
|
||||
if (this.displayedText.length === 0) {
|
||||
element.innerHTML = "";
|
||||
}
|
||||
|
||||
this._typeCharacter(element, text);
|
||||
}
|
||||
|
||||
async _typeCharacter(element, text) {
|
||||
if (this.displayedText.length < text.length) {
|
||||
this.displayedText += text.charAt(this.displayedText.length);
|
||||
|
||||
try {
|
||||
const cookedText = await cook(this.displayedText);
|
||||
element.classList.add("cooked");
|
||||
element.innerHTML = htmlSafe(cookedText);
|
||||
} catch (error) {
|
||||
console.error("Error cooking text during typing: ", error);
|
||||
}
|
||||
|
||||
this.typingTimer = later(
|
||||
this,
|
||||
this._typeCharacter,
|
||||
element,
|
||||
text,
|
||||
STREAMED_TEXT_SPEED
|
||||
);
|
||||
} else {
|
||||
this.typingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
willRemove() {
|
||||
if (this.typingTimer) {
|
||||
cancel(this.typingTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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