Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e6ced4554 |
@@ -1,3 +1 @@
|
||||
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
|
||||
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
|
||||
3.1.0.beta3: 057fbe1ce6
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,8 +19,13 @@ module DiscourseAi
|
||||
use_pg_headlines_for_excerpt: false,
|
||||
)
|
||||
|
||||
model =
|
||||
DiscourseAi::Embeddings::Model.instantiate(
|
||||
SiteSetting.ai_embeddings_semantic_search_model,
|
||||
)
|
||||
|
||||
DiscourseAi::Embeddings::SemanticSearch
|
||||
.new(guardian)
|
||||
.new(guardian, model)
|
||||
.search_for_topics(query, page)
|
||||
.each { |topic_post| grouped_results.add(topic_post) }
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Summarization
|
||||
class SummaryController < ::ApplicationController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
requires_login
|
||||
|
||||
VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168]
|
||||
VALID_TARGETS = %w[chat_channel topic]
|
||||
|
||||
def show
|
||||
raise PluginDisabled unless SiteSetting.ai_summarization_enabled
|
||||
target_type = params[:target_type]
|
||||
|
||||
raise Discourse::InvalidParameters.new(:target_type) if !VALID_TARGETS.include?(target_type)
|
||||
|
||||
since = nil
|
||||
|
||||
if target_type == "chat_channel"
|
||||
since = params[:since].to_i
|
||||
raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
|
||||
target = Chat::Channel.find_by(id: params[:target_id])
|
||||
raise Discourse::NotFound.new(:chat_channel) if !target
|
||||
raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(target)
|
||||
else
|
||||
target = Topic.find_by(id: params[:target_id])
|
||||
raise Discourse::NotFound.new(:topic) if !target
|
||||
raise Discourse::InvalidAccess if !guardian.can_see_topic?(target)
|
||||
end
|
||||
|
||||
RateLimiter.new(
|
||||
current_user,
|
||||
"ai_summarization",
|
||||
6,
|
||||
SiteSetting.ai_summarization_rate_limit_minutes.minutes,
|
||||
).performed!
|
||||
|
||||
hijack do
|
||||
summary =
|
||||
DiscourseAi::Summarization::SummaryGenerator.new(target, current_user).summarize!(since)
|
||||
|
||||
render json: { summary: summary }, status: 200
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,6 @@ class AiApiAuditLog < ActiveRecord::Base
|
||||
module Provider
|
||||
OpenAI = 1
|
||||
Anthropic = 2
|
||||
HuggingFaceTextGeneration = 3
|
||||
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,11 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiChatChannelSerializer < ApplicationSerializer
|
||||
attributes :id, :chatable, :chatable_type, :chatable_url, :slug
|
||||
|
||||
def title
|
||||
# Display all participants for a DM.
|
||||
# For category channels, the argument is ignored.
|
||||
object.title(nil)
|
||||
end
|
||||
attributes :id, :chatable, :chatable_type, :chatable_url, :title, :slug
|
||||
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
|
||||
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("|");
|
||||
}
|
||||
|
||||
#composeAiBotMessage(targetBot) {
|
||||
this.args.closePanel();
|
||||
composeAiBotMessage(targetBot, this.composer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<DModalBody @title="discourse_ai.summarization.title">
|
||||
{{#if @allowTimeframe}}
|
||||
<span>{{i18n "discourse_ai.summarization.description"}}</span>
|
||||
<ComboBox
|
||||
@value={{this.sinceHours}}
|
||||
@content={{this.sinceOptions}}
|
||||
@onChange={{action this.summarize}}
|
||||
@valueProperty="value"
|
||||
@class="summarization-since"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<div class="channel-summary">
|
||||
<ConditionalLoadingSpinner @condition={{this.loading}} />
|
||||
|
||||
{{#unless this.loading}}
|
||||
<Textarea @value={{this.summary}} disabled="true" class="summary-area" />
|
||||
{{/unless}}
|
||||
</div>
|
||||
|
||||
</DModalBody>
|
||||
|
||||
<div class="modal-footer">
|
||||
{{#unless @allowTimeframe}}
|
||||
<DButton
|
||||
@class="btn-primary create"
|
||||
@action={{this.summarize}}
|
||||
@label="discourse_ai.summarization.summarize"
|
||||
/>
|
||||
{{/unless}}
|
||||
<DModalCancel @close={{route-action "closeModal"}} />
|
||||
</div>
|
||||
@@ -0,0 +1,83 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class AiSummary extends Component {
|
||||
@tracked sinceHours = null;
|
||||
@tracked loading = false;
|
||||
@tracked availableSummaries = {};
|
||||
@tracked summary = null;
|
||||
sinceOptions = [
|
||||
{
|
||||
name: I18n.t("discourse_ai.summarization.since", { count: 1 }),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: I18n.t("discourse_ai.summarization.since", { count: 3 }),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: I18n.t("discourse_ai.summarization.since", { count: 6 }),
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
name: I18n.t("discourse_ai.summarization.since", { count: 12 }),
|
||||
value: 12,
|
||||
},
|
||||
{
|
||||
name: I18n.t("discourse_ai.summarization.since", { count: 24 }),
|
||||
value: 24,
|
||||
},
|
||||
{
|
||||
name: I18n.t("discourse_ai.summarization.since", { count: 72 }),
|
||||
value: 72,
|
||||
},
|
||||
{
|
||||
name: I18n.t("discourse_ai.summarization.since", { count: 168 }),
|
||||
value: 168,
|
||||
},
|
||||
];
|
||||
|
||||
get canSummarize() {
|
||||
return (!this.args.allowTimeframe || this.sinceHours) && !this.loading;
|
||||
}
|
||||
|
||||
@action
|
||||
summarize(value) {
|
||||
this.loading = true;
|
||||
const attrs = {
|
||||
target_id: this.args.targetId,
|
||||
target_type: this.args.targetType,
|
||||
};
|
||||
|
||||
if (this.args.allowTimeframe) {
|
||||
this.sinceHours = value;
|
||||
|
||||
if (this.availableSummaries[this.sinceHours]) {
|
||||
this.summary = this.availableSummaries[this.sinceHours];
|
||||
this.loading = false;
|
||||
return;
|
||||
} else {
|
||||
attrs.since = this.sinceHours;
|
||||
}
|
||||
}
|
||||
|
||||
ajax("/discourse-ai/summarization/summary", {
|
||||
method: "POST",
|
||||
data: attrs,
|
||||
})
|
||||
.then((data) => {
|
||||
if (this.args.allowTimeframe) {
|
||||
this.availableSummaries[this.sinceHours] = data.summary;
|
||||
this.summary = this.availableSummaries[this.sinceHours];
|
||||
} else {
|
||||
this.summary = data.summary;
|
||||
}
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => (this.loading = false));
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DModal from "discourse/components/d-modal";
|
||||
import DModalCancel from "discourse/components/d-modal-cancel";
|
||||
import I18n from "I18n";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
|
||||
const t = I18n.t.bind(I18n);
|
||||
|
||||
export default class ModalDiffModal extends Component {
|
||||
<template>
|
||||
<DModal
|
||||
class="composer-ai-helper-modal"
|
||||
@title={{t "discourse_ai.ai_helper.context_menu.changes"}}
|
||||
@closeModal={{@closeModal}}
|
||||
>
|
||||
<:body>
|
||||
{{#if @diff}}
|
||||
{{htmlSafe @diff}}
|
||||
{{else}}
|
||||
<div class="composer-ai-helper-modal__old-value">
|
||||
{{@oldValue}}
|
||||
</div>
|
||||
|
||||
<div class="composer-ai-helper-modal__new-value">
|
||||
{{@newValue}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</:body>
|
||||
|
||||
<:footer>
|
||||
<DButton
|
||||
class="btn-primary confirm"
|
||||
@action={{this.triggerConfirmChanges}}
|
||||
@label="discourse_ai.ai_helper.context_menu.confirm"
|
||||
/>
|
||||
<DModalCancel @close={{@closeModal}} />
|
||||
</:footer>
|
||||
</DModal>
|
||||
</template>
|
||||
|
||||
@action
|
||||
triggerConfirmChanges() {
|
||||
this.args.closeModal();
|
||||
this.args.confirm();
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<div {{did-insert this.setupContextMenu}}>
|
||||
{{#if this.showContextMenu}}
|
||||
<div class="ai-helper-context-menu">
|
||||
{{#if (eq this.menuState this.CONTEXT_MENU_STATES.triggers)}}
|
||||
<ul class="ai-helper-context-menu__trigger">
|
||||
<li>
|
||||
<DButton
|
||||
@icon="magic"
|
||||
@action={{this.toggleAiHelperOptions}}
|
||||
@label="discourse_ai.ai_helper.context_menu.trigger"
|
||||
class="btn-flat"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}}
|
||||
<ul class="ai-helper-context-menu__options">
|
||||
{{#each this.helperOptions as |option|}}
|
||||
<li data-name={{option.name}} data-value={{option.value}}>
|
||||
<DButton
|
||||
@class="btn-flat"
|
||||
@translatedLabel={{option.name}}
|
||||
@action={{this.updateSelected}}
|
||||
@actionParam={{option.value}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.suggestions)}}
|
||||
<ul class="ai-helper-context-menu__suggestions">
|
||||
{{#each this.generatedTitleSuggestions as |suggestion index|}}
|
||||
<li data-name={{suggestion}} data-value={{index}}>
|
||||
<DButton
|
||||
@class="btn-flat"
|
||||
@translatedLabel={{suggestion}}
|
||||
@action={{this.updateTopicTitle}}
|
||||
@actionParam={{suggestion}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}}
|
||||
<ul class="ai-helper-context-menu__loading">
|
||||
<li>
|
||||
<div class="dot-falling"></div>
|
||||
<span>
|
||||
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.review)}}
|
||||
<ul class="ai-helper-context-menu__review">
|
||||
<li>
|
||||
<DButton
|
||||
@icon="exchange-alt"
|
||||
@label="discourse_ai.ai_helper.context_menu.view_changes"
|
||||
@action={{this.viewChanges}}
|
||||
class="btn-flat view-changes"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<DButton
|
||||
@icon="undo"
|
||||
@label="discourse_ai.ai_helper.context_menu.revert"
|
||||
@action={{this.undoAIAction}}
|
||||
class="btn-flat revert"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<DButton
|
||||
@icon="check"
|
||||
@label="discourse_ai.ai_helper.context_menu.confirm"
|
||||
@action={{this.confirmChanges}}
|
||||
class="btn-flat confirm"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.resets)}}
|
||||
<ul class="ai-helper-context-menu__resets">
|
||||
<li>
|
||||
<DButton
|
||||
@icon="undo"
|
||||
@label="discourse_ai.ai_helper.context_menu.undo"
|
||||
@action={{this.undoAIAction}}
|
||||
class="btn-flat undo"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<DButton
|
||||
@icon="discourse-sparkles"
|
||||
@label="discourse_ai.ai_helper.context_menu.regen"
|
||||
@action={{this.updateSelected}}
|
||||
@actionParam={{this.lastUsedOption}}
|
||||
class="btn-flat"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.showDiffModal}}
|
||||
<Modal::DiffModal
|
||||
@confirm={{this.confirmChanges}}
|
||||
@diff={{this.diff}}
|
||||
@oldValue={{this.selectedText}}
|
||||
@newValue={{this.newSelectedText}}
|
||||
@closeModal={{fn (mut this.showDiffModal) false}}
|
||||
/>
|
||||
{{/if}}
|
||||
@@ -1,292 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { afterRender, bind, debounce } from "discourse-common/utils/decorators";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import { caretPosition, getCaretPosition } from "discourse/lib/utilities";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class AiHelperContextMenu extends Component {
|
||||
static shouldRender(outletArgs, helper) {
|
||||
const helperEnabled =
|
||||
helper.siteSettings.discourse_ai_enabled &&
|
||||
helper.siteSettings.composer_ai_helper_enabled;
|
||||
|
||||
const allowedGroups = helper.siteSettings.ai_helper_allowed_groups
|
||||
.split("|")
|
||||
.map((id) => parseInt(id, 10));
|
||||
const canUseAssistant = helper.currentUser?.groups.some((g) =>
|
||||
allowedGroups.includes(g.id)
|
||||
);
|
||||
|
||||
const canShowInPM = helper.siteSettings.ai_helper_allowed_in_pm;
|
||||
|
||||
if (outletArgs?.composer?.privateMessage) {
|
||||
return helperEnabled && canUseAssistant && canShowInPM;
|
||||
}
|
||||
|
||||
return helperEnabled && canUseAssistant;
|
||||
}
|
||||
|
||||
@service siteSettings;
|
||||
@tracked helperOptions = [];
|
||||
@tracked showContextMenu = false;
|
||||
@tracked menuState = this.CONTEXT_MENU_STATES.triggers;
|
||||
@tracked caretCoords;
|
||||
@tracked virtualElement;
|
||||
@tracked selectedText = "";
|
||||
@tracked newSelectedText;
|
||||
@tracked loading = false;
|
||||
@tracked oldEditorValue;
|
||||
@tracked newEditorValue;
|
||||
@tracked generatedTitleSuggestions = [];
|
||||
@tracked lastUsedOption = null;
|
||||
@tracked showDiffModal = false;
|
||||
@tracked diff;
|
||||
|
||||
CONTEXT_MENU_STATES = {
|
||||
triggers: "TRIGGERS",
|
||||
options: "OPTIONS",
|
||||
resets: "RESETS",
|
||||
loading: "LOADING",
|
||||
suggesions: "SUGGESTIONS",
|
||||
review: "REVIEW",
|
||||
};
|
||||
prompts = [];
|
||||
promptTypes = {};
|
||||
|
||||
@tracked _popper;
|
||||
@tracked _dEditorInput;
|
||||
@tracked _contextMenu;
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
document.removeEventListener("selectionchange", this.selectionChanged);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
this._popper?.destroy();
|
||||
}
|
||||
|
||||
async loadPrompts() {
|
||||
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
||||
|
||||
prompts.map((p) => {
|
||||
this.prompts[p.id] = p;
|
||||
});
|
||||
|
||||
this.promptTypes = prompts.reduce((memo, p) => {
|
||||
memo[p.name] = p.prompt_type;
|
||||
return memo;
|
||||
}, {});
|
||||
|
||||
this.helperOptions = prompts.map((p) => {
|
||||
return {
|
||||
name: p.translated_name,
|
||||
value: p.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
selectionChanged() {
|
||||
if (document.activeElement !== this._dEditorInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canSelect = Boolean(
|
||||
window.getSelection() &&
|
||||
document.activeElement &&
|
||||
document.activeElement.value
|
||||
);
|
||||
|
||||
this.selectedText = canSelect
|
||||
? document.activeElement.value.substring(
|
||||
document.activeElement.selectionStart,
|
||||
document.activeElement.selectionEnd
|
||||
)
|
||||
: "";
|
||||
|
||||
if (this.selectedText.length === 0) {
|
||||
if (this.loading || this.menuState === this.CONTEXT_MENU_STATES.review) {
|
||||
// prevent accidentally closing context menu
|
||||
// while results loading or in review state
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
this._onSelectionChanged();
|
||||
}
|
||||
|
||||
@bind
|
||||
updatePosition() {
|
||||
if (!this.showContextMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.positionContextMenu();
|
||||
}
|
||||
|
||||
@bind
|
||||
onKeyDown(event) {
|
||||
const cmdOrCtrl = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (cmdOrCtrl && event.key === "z" && this.oldEditorValue) {
|
||||
return this.undoAIAction();
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
return this.closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
@debounce(INPUT_DELAY)
|
||||
_onSelectionChanged() {
|
||||
this.positionContextMenu();
|
||||
this.showContextMenu = true;
|
||||
}
|
||||
|
||||
generateGetBoundingClientRect(width = 0, height = 0, x = 0, y = 0) {
|
||||
return () => ({
|
||||
width,
|
||||
height,
|
||||
top: y,
|
||||
right: x,
|
||||
bottom: y,
|
||||
left: x,
|
||||
});
|
||||
}
|
||||
|
||||
closeContextMenu() {
|
||||
this.showContextMenu = false;
|
||||
this.menuState = this.CONTEXT_MENU_STATES.triggers;
|
||||
}
|
||||
|
||||
_updateSuggestedByAI(data) {
|
||||
const composer = this.args.outletArgs.composer;
|
||||
this.oldEditorValue = this._dEditorInput.value;
|
||||
this.newSelectedText = data.suggestions[0];
|
||||
|
||||
this.newEditorValue = this.oldEditorValue.replace(
|
||||
this.selectedText,
|
||||
this.newSelectedText
|
||||
);
|
||||
|
||||
if (data.diff) {
|
||||
this.diff = data.diff;
|
||||
}
|
||||
composer.set("reply", this.newEditorValue);
|
||||
this.menuState = this.CONTEXT_MENU_STATES.review;
|
||||
}
|
||||
|
||||
@afterRender
|
||||
positionContextMenu() {
|
||||
this._contextMenu = document.querySelector(".ai-helper-context-menu");
|
||||
this.caretCoords = getCaretPosition(this._dEditorInput, {
|
||||
pos: caretPosition(this._dEditorInput),
|
||||
});
|
||||
|
||||
this.virtualElement = {
|
||||
getBoundingClientRect: this.generateGetBoundingClientRect(
|
||||
this._contextMenu.clientWidth,
|
||||
this._contextMenu.clientHeight,
|
||||
this.caretCoords.x,
|
||||
this.caretCoords.y
|
||||
),
|
||||
};
|
||||
|
||||
this._popper = createPopper(this.virtualElement, this._contextMenu, {
|
||||
placement: "top-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [10, 0],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
setupContextMenu() {
|
||||
document.addEventListener("selectionchange", this.selectionChanged);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this._dEditorInput = document.querySelector(".d-editor-input");
|
||||
|
||||
if (this._dEditorInput) {
|
||||
this._dEditorInput.addEventListener("scroll", this.updatePosition);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
toggleAiHelperOptions() {
|
||||
// Fetch prompts only if it hasn't been fetched yet
|
||||
if (this.helperOptions.length === 0) {
|
||||
this.loadPrompts();
|
||||
}
|
||||
this.menuState = this.CONTEXT_MENU_STATES.options;
|
||||
}
|
||||
|
||||
@action
|
||||
undoAIAction() {
|
||||
const composer = this.args.outletArgs.composer;
|
||||
composer.set("reply", this.oldEditorValue);
|
||||
this.closeContextMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
async updateSelected(option) {
|
||||
this.loading = true;
|
||||
this.lastUsedOption = option;
|
||||
this._dEditorInput.classList.add("loading");
|
||||
this.menuState = this.CONTEXT_MENU_STATES.loading;
|
||||
|
||||
return ajax("/discourse-ai/ai-helper/suggest", {
|
||||
method: "POST",
|
||||
data: { mode: option, text: this.selectedText },
|
||||
})
|
||||
.then((data) => {
|
||||
// resets the values if new suggestion is started:
|
||||
this.diff = null;
|
||||
this.newSelectedText = null;
|
||||
|
||||
if (this.prompts[option].name === "generate_titles") {
|
||||
this.menuState = this.CONTEXT_MENU_STATES.suggestions;
|
||||
this.generatedTitleSuggestions = data.suggestions;
|
||||
} else {
|
||||
this._updateSuggestedByAI(data);
|
||||
}
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
this._dEditorInput.classList.remove("loading");
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
updateTopicTitle(title) {
|
||||
const composer = this.args.outletArgs?.composer;
|
||||
|
||||
if (composer) {
|
||||
composer.set("title", title);
|
||||
this.closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
viewChanges() {
|
||||
this.showDiffModal = true;
|
||||
}
|
||||
|
||||
@action
|
||||
confirmChanges() {
|
||||
this.menuState = this.CONTEXT_MENU_STATES.resets;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{{#if this.isAiBotChat}}
|
||||
<DSection @bodyClass="ai-bot-chat" />
|
||||
{{#if this.renderChatWarning}}
|
||||
<div class="ai-bot-chat-warning">{{i18n
|
||||
"discourse_ai.ai_bot.pm_warning"
|
||||
}}</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -1,36 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { computed } from "@ember/object";
|
||||
|
||||
export default class extends Component {
|
||||
static shouldRender() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
|
||||
get composerModel() {
|
||||
return this.args?.outletArgs?.composer;
|
||||
}
|
||||
|
||||
get renderChatWarning() {
|
||||
return this.siteSettings.ai_bot_enable_chat_warning;
|
||||
}
|
||||
|
||||
@computed("composerModel.targetRecipients")
|
||||
get isAiBotChat() {
|
||||
if (
|
||||
this.composerModel &&
|
||||
this.composerModel.targetRecipients &&
|
||||
this.currentUser.ai_enabled_chat_bots
|
||||
) {
|
||||
let reciepients = this.composerModel.targetRecipients.split(",");
|
||||
|
||||
return this.currentUser.ai_enabled_chat_bots.any((bot) =>
|
||||
reciepients.any((username) => username === bot.username)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
shouldRender(args) {
|
||||
return (args.model.related_topics?.length || 0) > 0;
|
||||
},
|
||||
setupComponent(args, component) {
|
||||
if (component.model.related_topics) {
|
||||
component.set(
|
||||
"relatedTopics",
|
||||
component.model.related_topics.map((topic) =>
|
||||
this.store.createRecord("topic", topic)
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
<div
|
||||
id="related-topics"
|
||||
class="more-topics__list {{if this.hidden 'hidden'}}"
|
||||
role="complementary"
|
||||
aria-labelledby="related-topics-title"
|
||||
{{did-insert this.registerList}}
|
||||
{{will-destroy this.removeList}}
|
||||
>
|
||||
<h3 id="related-topics-title" class="more-topics__list-title">
|
||||
{{i18n "discourse_ai.related_topics.title"}}
|
||||
</h3>
|
||||
<div class="topics">
|
||||
<BasicTopicList @topics={{this.relatedTopics}} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,41 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action, computed } from "@ember/object";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class extends Component {
|
||||
static shouldRender(args) {
|
||||
return (args.model.related_topics?.length || 0) > 0;
|
||||
}
|
||||
|
||||
@service store;
|
||||
@service site;
|
||||
@service moreTopicsPreferenceTracking;
|
||||
|
||||
listId = "related-topics";
|
||||
|
||||
@computed("moreTopicsPreferenceTracking.selectedTab")
|
||||
get hidden() {
|
||||
return this.moreTopicsPreferenceTracking.selectedTab !== this.listId;
|
||||
}
|
||||
|
||||
get relatedTopics() {
|
||||
return this.args.outletArgs.model.related_topics.map((topic) =>
|
||||
this.store.createRecord("topic", topic)
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
registerList() {
|
||||
this.moreTopicsPreferenceTracking.registerTopicList({
|
||||
name: I18n.t("discourse_ai.related_topics.pill"),
|
||||
id: this.listId,
|
||||
icon: "magic",
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
removeList() {
|
||||
this.moreTopicsPreferenceTracking.removeTopicList(this.listId);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import Composer from "discourse/models/composer";
|
||||
import I18n from "I18n";
|
||||
|
||||
export function composeAiBotMessage(targetBot, composer) {
|
||||
const currentUser = composer.currentUser;
|
||||
|
||||
let botUsername = currentUser.ai_enabled_chat_bots.find(
|
||||
(bot) => bot.model_name === targetBot
|
||||
).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,
|
||||
},
|
||||
});
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<div>
|
||||
<h3 id="related-topics-title" class="related-topics-title">
|
||||
{{i18n "discourse_ai.related_topics.title"}}
|
||||
</h3>
|
||||
<BasicTopicList @topics={{this.relatedTopics}} />
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<AiSummary
|
||||
@targetId={{this.targetId}}
|
||||
@targetType={{this.targetType}}
|
||||
@allowTimeframe={{this.allowTimeframe}}
|
||||
@closeModal={{route-action "closeModal"}}
|
||||
/>
|
||||
@@ -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((id) => parseInt(id, 10));
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
function initializeChatChannelSummary(api) {
|
||||
const chat = api.container.lookup("service:chat");
|
||||
if (chat) {
|
||||
api.registerChatComposerButton?.({
|
||||
translatedLabel: "discourse_ai.summarization.title",
|
||||
id: "chat_channel_summary",
|
||||
icon: "discourse-sparkles",
|
||||
action: "showChannelSummary",
|
||||
position: "dropdown",
|
||||
});
|
||||
|
||||
api.modifyClass("component:chat-composer", {
|
||||
pluginId: "discourse-ai",
|
||||
|
||||
@action
|
||||
showChannelSummary() {
|
||||
showModal("ai-summary").setProperties({
|
||||
targetId: this.args.channel.id,
|
||||
targetType: "chat_channel",
|
||||
allowTimeframe: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "discourse-ai-chat-channel-summary",
|
||||
|
||||
initialize(container) {
|
||||
const settings = container.lookup("service:site-settings");
|
||||
const summarizationEnabled =
|
||||
settings.discourse_ai_enabled && settings.ai_summarization_enabled;
|
||||
|
||||
if (summarizationEnabled) {
|
||||
withPluginApi("1.6.0", initializeChatChannelSummary);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
|
||||
const allowedGroups = settings.ai_helper_allowed_groups
|
||||
.split("|")
|
||||
.map((id) => parseInt(id, 10));
|
||||
.map(parseInt);
|
||||
const canUseAssistant = user?.groups.some((g) =>
|
||||
allowedGroups.includes(g.id)
|
||||
);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
|
||||
export default {
|
||||
name: "discourse-ai-related-topics",
|
||||
|
||||
initialize(container) {
|
||||
const settings = container.lookup("service:site-settings");
|
||||
|
||||
if (settings.ai_embeddings_semantic_related_topics_enabled) {
|
||||
withPluginApi("1.1.0", (api) => {
|
||||
api.modifyClass("model:post-stream", {
|
||||
pluginId: "discourse-ai",
|
||||
|
||||
_setSuggestedTopics(result) {
|
||||
this._super(...arguments);
|
||||
|
||||
if (!result.related_topics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.topic.setProperties({
|
||||
related_topics: result.related_topics,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
|
||||
function initializeTopicSummary(api) {
|
||||
api.modifyClass("component:scrolling-post-stream", {
|
||||
pluginId: "discourse-ai",
|
||||
|
||||
showAiSummary() {
|
||||
showModal("ai-summary").setProperties({
|
||||
targetId: this.posts["posts"][0].topic_id,
|
||||
targetType: "topic",
|
||||
allowTimeframe: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
api.addTopicSummaryCallback((html, attrs, widget) => {
|
||||
html.push(
|
||||
widget.attach("button", {
|
||||
className: "btn btn-primary topic-ai-summarization",
|
||||
icon: "magic",
|
||||
title: "discourse_ai.summarization.title",
|
||||
label: "discourse_ai.summarization.title",
|
||||
action: "showAiSummary",
|
||||
})
|
||||
);
|
||||
|
||||
return html;
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "discourse-ai-topic-summary",
|
||||
|
||||
initialize(container) {
|
||||
const settings = container.lookup("service:site-settings");
|
||||
const summarizationEnabled =
|
||||
settings.discourse_ai_enabled && settings.ai_summarization_enabled;
|
||||
|
||||
if (summarizationEnabled) {
|
||||
withPluginApi("1.6.0", initializeTopicSummary);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -2,40 +2,6 @@ nav.post-controls .actions button.cancel-streaming {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-bot-chat #reply-control {
|
||||
.title-and-category {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-bot-chat-warning {
|
||||
color: var(--tertiary);
|
||||
background-color: var(--tertiary-low);
|
||||
border-top: 1px solid var(--tertiary-medium);
|
||||
opacity: 0.75;
|
||||
.d-icon {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
margin: 10px 0 10px 0;
|
||||
}
|
||||
|
||||
.text-preview,
|
||||
.inline-diff {
|
||||
.text-preview {
|
||||
ins {
|
||||
background-color: var(--success-low);
|
||||
text-decoration: underline;
|
||||
@@ -21,17 +20,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__old-value {
|
||||
background-color: var(--danger-low);
|
||||
color: var(--danger);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__new-value {
|
||||
background-color: var(--success-low);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.selection-hint {
|
||||
font-size: var(--font-down-2);
|
||||
margin-bottom: 20px;
|
||||
@@ -41,155 +29,3 @@
|
||||
.topic-above-suggested-outlet.related-topics {
|
||||
margin: 4.5em 0 1em;
|
||||
}
|
||||
|
||||
.ai-helper-context-menu {
|
||||
background: var(--secondary);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
padding: 0.25rem;
|
||||
max-width: 25rem;
|
||||
border: 1px solid var(--primary-low);
|
||||
list-style: none;
|
||||
z-index: 999;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
ul:not(.ai-helper-context-menu__loading) li {
|
||||
transition: background-color 0.25s ease;
|
||||
&:hover {
|
||||
background: var(--primary-low);
|
||||
}
|
||||
}
|
||||
|
||||
.d-button-label {
|
||||
color: var(--primary-very-high);
|
||||
}
|
||||
|
||||
&__options {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
.dot-falling {
|
||||
margin-inline: 1rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
gap: 1rem;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__resets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
&__review {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.d-editor-input.loading {
|
||||
animation: loading-text 1.5s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes loading-text {
|
||||
0% {
|
||||
color: var(--primary);
|
||||
}
|
||||
50% {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
100% {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
// AI Typing indicator (taken from: https://github.com/nzbin/three-dots)
|
||||
.dot-falling {
|
||||
position: relative;
|
||||
left: -9999px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
box-shadow: 9999px 0 0 0 var(--tertiary);
|
||||
animation: dot-falling 1s infinite linear;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.dot-falling::before,
|
||||
.dot-falling::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
.dot-falling::before {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
animation: dot-falling-before 1s infinite linear;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.dot-falling::after {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--tertiary);
|
||||
color: var(--tertiary);
|
||||
animation: dot-falling-after 1s infinite linear;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
@keyframes dot-falling {
|
||||
0% {
|
||||
box-shadow: 9999px -15px 0 0 rgba(152, 128, 255, 0);
|
||||
}
|
||||
25%,
|
||||
50%,
|
||||
75% {
|
||||
box-shadow: 9999px 0 0 0 var(--tertiary);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 9999px 15px 0 0 rgba(152, 128, 255, 0);
|
||||
}
|
||||
}
|
||||
@keyframes dot-falling-before {
|
||||
0% {
|
||||
box-shadow: 9984px -15px 0 0 rgba(152, 128, 255, 0);
|
||||
}
|
||||
25%,
|
||||
50%,
|
||||
75% {
|
||||
box-shadow: 9984px 0 0 0 var(--tertiary);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 9984px 15px 0 0 rgba(152, 128, 255, 0);
|
||||
}
|
||||
}
|
||||
@keyframes dot-falling-after {
|
||||
0% {
|
||||
box-shadow: 10014px -15px 0 0 rgba(152, 128, 255, 0);
|
||||
}
|
||||
25%,
|
||||
50%,
|
||||
75% {
|
||||
box-shadow: 10014px 0 0 0 var(--tertiary);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 10014px 15px 0 0 rgba(152, 128, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user