DEV: Clean up after #677 (#694)

Follow up to b863ddc94b

Ruby:
* Validate `summary` (the column is `not null`)
* Fix `name` validation (the column has `max_length` 100)
* Fix table annotations
* Accept missing `parameter` attributes (`required, `enum`, `enum_values`)

JS:
* Use native classes
* Don't use ember's array extensions
* Add explicit service injections
* Correct class names
* Use `||=` operator
* Use `store` service to create records
* Remove unused service injections
* Extract consts
* Group actions together
* Use `async`/`await`
* Use `withEventValue`
* Sort html attributes
* Use DButtons `@label` arg
* Use `input` elements instead of Ember's `Input` component (same w/ textarea)
* Remove `btn-default` class (automatically applied by DButton)
* Don't mix `I18n.t` and `i18n` in the same template
* Don't track props that aren't used in a template
* Correct invalid `target.value` code
* Remove unused/invalid `this.parameter`/`onChange` code
* Whitespace
* Use the new service import `inject as service` -> `service`
* Use `Object.entries()`
* Add missing i18n strings
* Fix an error in `addEnumValue` (calling `pushObject` on `undefined`)
* Use `TrackedArray`/`TrackedObject`
* Transform tool `parameters` keys (`enumValues` -> `enum_values`)
This commit is contained in:
Jarek Radosz 2024-06-28 00:59:51 +02:00 committed by GitHub
parent a708d4dfa2
commit a5a39dd2ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 197 additions and 178 deletions

View File

@ -1,16 +1,15 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
export default class DiscourseAiToolsNewRoute extends DiscourseRoute {
async model() {
const record = this.store.createRecord("ai-tool");
return record;
},
return this.store.createRecord("ai-tool");
}
setupController(controller, model) {
this._super(controller, model);
setupController(controller) {
super.setupController(...arguments);
const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
controller.set("allTools", toolsModel);
controller.set("presets", toolsModel.resultSetMeta.presets);
},
});
}
}

View File

@ -1,17 +1,18 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
export default class DiscourseAiToolsShowRoute extends DiscourseRoute {
async model(params) {
const allTools = this.modelFor("adminPlugins.show.discourse-ai-tools");
const id = parseInt(params.id, 10);
return allTools.findBy("id", id);
},
setupController(controller, model) {
this._super(controller, model);
return allTools.find((tool) => tool.id === id);
}
setupController(controller) {
super.setupController(...arguments);
const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
controller.set("allTools", toolsModel);
controller.set("presets", toolsModel.resultSetMeta.presets);
},
});
}
}

View File

@ -1,6 +1,9 @@
import { service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiToolsRoute extends DiscourseRoute {
@service store;
model() {
return this.store.findAll("ai-tool");
}

View File

@ -81,7 +81,7 @@ module DiscourseAi
:description,
:script,
:summary,
parameters: %i[name type description],
parameters: [:name, :type, :description, :required, :enum, enum_values: []],
)
end
end

View File

@ -1,8 +1,9 @@
# frozen_string_literal: true
class AiTool < ActiveRecord::Base
validates :name, presence: true, length: { maximum: 255 }
validates :name, presence: true, length: { maximum: 100 }
validates :description, presence: true, length: { maximum: 1000 }
validates :summary, presence: true, length: { maximum: 255 }
validates :script, presence: true, length: { maximum: 100_000 }
validates :created_by_id, presence: true
belongs_to :created_by, class_name: "User"
@ -174,10 +175,12 @@ end
#
# id :bigint not null, primary key
# name :string not null
# description :text not null
# description :string not null
# summary :string not null
# parameters :jsonb not null
# script :text not null
# created_by_id :integer not null
# enabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -1,6 +1,6 @@
import RestAdapter from "discourse/adapters/rest";
export default class Adapter extends RestAdapter {
export default class AiToolAdapter extends RestAdapter {
jsonMode = true;
basePath() {

View File

@ -1,3 +1,4 @@
import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
import RestModel from "discourse/models/rest";
const CREATE_ATTRIBUTES = [
@ -20,8 +21,21 @@ export default class AiTool extends RestModel {
}
workingCopy() {
let attrs = this.getProperties(CREATE_ATTRIBUTES);
attrs.parameters = attrs.parameters || [];
return AiTool.create(attrs);
const attrs = this.getProperties(CREATE_ATTRIBUTES);
attrs.parameters = new TrackedArray(
attrs.parameters?.map((p) => {
const parameter = new TrackedObject(p);
if (parameter.enum_values) {
parameter.enumValues = new TrackedArray(parameter.enum_values);
delete parameter.enum_values;
}
return parameter;
})
);
return this.store.createRecord("ai-tool", attrs);
}
}

View File

@ -1,14 +1,15 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import BackButton from "discourse/components/back-button";
import DButton from "discourse/components/d-button";
import Textarea from "discourse/components/d-textarea";
import DTooltip from "discourse/components/d-tooltip";
import withEventValue from "discourse/helpers/with-event-value";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
import AceEditor from "admin/components/ace-editor";
@ -16,9 +17,11 @@ import ComboBox from "select-kit/components/combo-box";
import AiToolParameterEditor from "./ai-tool-parameter-editor";
import AiToolTestModal from "./modal/ai-tool-test-modal";
const ACE_EDITOR_MODE = "javascript";
const ACE_EDITOR_THEME = "chrome";
export default class AiToolEditor extends Component {
@service router;
@service store;
@service dialog;
@service modal;
@service toasts;
@ -26,18 +29,8 @@ export default class AiToolEditor extends Component {
@tracked isSaving = false;
@tracked editingModel = null;
@tracked showDelete = false;
@tracked selectedPreset = null;
aceEditorMode = "javascript";
aceEditorTheme = "chrome";
@action
updateModel() {
this.editingModel = this.args.model.workingCopy();
this.showDelete = !this.args.model.isNew;
}
get presets() {
return this.args.presets.map((preset) => {
return {
@ -51,6 +44,12 @@ export default class AiToolEditor extends Component {
return !this.selectedPreset && this.args.model.isNew;
}
@action
updateModel() {
this.editingModel = this.args.model.workingCopy();
this.showDelete = !this.args.model.isNew;
}
@action
configurePreset() {
this.selectedPreset = this.args.presets.findBy("preset_id", this.presetId);
@ -64,16 +63,23 @@ export default class AiToolEditor extends Component {
this.isSaving = true;
try {
await this.args.model.save(
this.editingModel.getProperties(
"name",
"description",
"parameters",
"script",
"summary"
)
const data = this.editingModel.getProperties(
"name",
"description",
"parameters",
"script",
"summary"
);
for (const p of data.parameters) {
if (p.enumValues) {
p.enum_values = p.enumValues;
delete p.enumValues;
}
}
await this.args.model.save(data);
this.toasts.success({
data: { message: I18n.t("discourse_ai.tools.saved") },
duration: 2000,
@ -97,22 +103,15 @@ export default class AiToolEditor extends Component {
delete() {
return this.dialog.confirm({
message: I18n.t("discourse_ai.tools.confirm_delete"),
didConfirm: () => {
return this.args.model.destroyRecord().then(() => {
this.args.tools.removeObject(this.args.model);
this.router.transitionTo(
"adminPlugins.show.discourse-ai-tools.index"
);
});
didConfirm: async () => {
await this.args.model.destroyRecord();
this.args.tools.removeObject(this.args.model);
this.router.transitionTo("adminPlugins.show.discourse-ai-tools.index");
},
});
}
@action
updateScript(script) {
this.editingModel.script = script;
}
@action
openTestModal() {
this.modal.show(AiToolTestModal, {
@ -129,9 +128,9 @@ export default class AiToolEditor extends Component {
/>
<form
class="form-horizontal ai-tool-editor"
{{didUpdate this.updateModel @model.id}}
{{didInsert this.updateModel @model.id}}
{{didUpdate this.updateModel @model.id}}
class="form-horizontal ai-tool-editor"
>
{{#if this.showPresets}}
<div class="control-group">
@ -145,18 +144,18 @@ export default class AiToolEditor extends Component {
<div class="control-group ai-llm-editor__action_panel">
<DButton
class="ai-tool-editor__next"
@action={{this.configurePreset}}
>
{{I18n.t "discourse_ai.tools.next.title"}}
</DButton>
@label="discourse_ai.tools.next.title"
class="ai-tool-editor__next"
/>
</div>
{{else}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.name"}}</label>
<Input
@type="text"
@value={{this.editingModel.name}}
<input
{{on "input" (withEventValue (fn (mut this.editingModel.name)))}}
value={{this.editingModel.name}}
type="text"
class="ai-tool-editor__name"
/>
<DTooltip
@ -164,19 +163,25 @@ export default class AiToolEditor extends Component {
@content={{I18n.t "discourse_ai.tools.name_help"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.description"}}</label>
<Textarea
@value={{this.editingModel.description}}
class="ai-tool-editor__description input-xxlarge"
<textarea
{{on
"input"
(withEventValue (fn (mut this.editingModel.description)))
}}
placeholder={{I18n.t "discourse_ai.tools.description_help"}}
/>
class="ai-tool-editor__description input-xxlarge"
>{{this.editingModel.description}}</textarea>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.summary"}}</label>
<Input
@type="text"
@value={{this.editingModel.summary}}
<input
{{on "input" (withEventValue (fn (mut this.editingModel.summary)))}}
value={{this.editingModel.summary}}
type="text"
class="ai-tool-editor__summary input-xxlarge"
/>
<DTooltip
@ -184,37 +189,43 @@ export default class AiToolEditor extends Component {
@content={{I18n.t "discourse_ai.tools.summary_help"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.parameters"}}</label>
<AiToolParameterEditor @parameters={{this.editingModel.parameters}} />
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.script"}}</label>
<AceEditor
@content={{this.editingModel.script}}
@mode={{this.aceEditorMode}}
@theme={{this.aceEditorTheme}}
@onChange={{this.updateScript}}
@onChange={{withEventValue (fn (mut this.editingModel.script))}}
@mode={{ACE_EDITOR_MODE}}
@theme={{ACE_EDITOR_THEME}}
@editorId="ai-tool-script-editor"
/>
</div>
<div class="control-group ai-tool-editor__action_panel">
<DButton
@action={{this.openTestModal}}
class="btn-default ai-tool-editor__test-button"
>{{I18n.t "discourse_ai.tools.test"}}</DButton>
@label="discourse_ai.tools.test"
class="ai-tool-editor__test-button"
/>
<DButton
class="btn-primary ai-tool-editor__save"
@action={{this.save}}
@label="discourse_ai.tools.save"
@disabled={{this.isSaving}}
>{{I18n.t "discourse_ai.tools.save"}}</DButton>
class="btn-primary ai-tool-editor__save"
/>
{{#if this.showDelete}}
<DButton
@action={{this.delete}}
@label="discourse_ai.tools.delete"
class="btn-danger ai-tool-editor__delete"
>
{{I18n.t "discourse_ai.tools.delete"}}
</DButton>
/>
{{/if}}
</div>
{{/if}}

View File

@ -1,12 +1,11 @@
import { LinkTo } from "@ember/routing";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
<template>
<section class="ai-tool-list-editor__current admin-detail pull-left">
<div class="ai-tool-list-editor__header">
<h3>{{i18n "discourse_ai.tools.short_title"}}</h3>
<h3>{{I18n.t "discourse_ai.tools.short_title"}}</h3>
<LinkTo
@route="adminPlugins.show.discourse-ai-tools.new"
class="btn btn-small btn-primary ai-tool-list-editor__new-button"
@ -37,7 +36,7 @@ import I18n from "discourse-i18n";
@route="adminPlugins.show.discourse-ai-tools.show"
@model={{tool}}
class="btn btn-text btn-small"
>{{i18n "discourse_ai.tools.edit"}} </LinkTo>
>{{I18n.t "discourse_ai.tools.edit"}}</LinkTo>
</td>
</tr>
{{/each}}

View File

@ -1,10 +1,10 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import I18n from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
@ -16,145 +16,135 @@ const PARAMETER_TYPES = [
];
export default class AiToolParameterEditor extends Component {
@tracked parameters = [];
@action
addParameter() {
this.args.parameters.pushObject({
name: "",
description: "",
type: "string",
required: false,
enum: false,
enumValues: [],
});
this.args.parameters.push(
new TrackedObject({
name: "",
description: "",
type: "string",
required: false,
enum: false,
enumValues: null,
})
);
}
@action
removeParameter(parameter) {
this.args.parameters.removeObject(parameter);
const index = this.args.parameters.indexOf(parameter);
this.args.parameters.splice(index, 1);
}
@action
updateParameter(parameter, field, value) {
parameter[field] = value;
toggleRequired(parameter, event) {
parameter.required = event.target.checked;
}
@action
toggleEnum(parameter) {
parameter.enum = !parameter.enum;
if (!parameter.enum) {
parameter.enumValues = [];
parameter.enumValues = null;
}
this.args.onChange(this.parameters);
}
@action
addEnumValue(parameter) {
parameter.enumValues.pushObject("");
parameter.enumValues ||= new TrackedArray();
parameter.enumValues.push("");
}
@action
removeEnumValue(parameter, index) {
parameter.enumValues.removeAt(index);
parameter.enumValues.splice(index, 1);
}
@action
updateEnumValue(parameter, index, event) {
parameter.enumValues[index] = event.target.value;
}
<template>
{{#each @parameters as |parameter|}}
<div class="ai-tool-parameter">
<div class="parameter-row">
<Input
@type="text"
@value={{parameter.name}}
<input
{{on "input" (withEventValue (fn (mut parameter.name)))}}
value={{parameter.name}}
type="text"
placeholder={{I18n.t "discourse_ai.tools.parameter_name"}}
/>
<ComboBox @value={{parameter.type}} @content={{PARAMETER_TYPES}} />
</div>
<div class="parameter-row">
<Input
@type="text"
@value={{parameter.description}}
<input
{{on "input" (withEventValue (fn (mut parameter.description)))}}
value={{parameter.description}}
type="text"
placeholder={{I18n.t "discourse_ai.tools.parameter_description"}}
{{on
"input"
(fn
this.updateParameter
parameter
"description"
value="target.value"
)
}}
/>
</div>
<div class="parameter-row">
<label>
<Input
@type="checkbox"
@checked={{parameter.required}}
{{on
"change"
(fn
this.updateParameter
parameter
"required"
value="target.checked"
)
}}
<input
{{on "input" (fn this.toggleRequired parameter)}}
checked={{parameter.required}}
type="checkbox"
/>
{{I18n.t "discourse_ai.tools.parameter_required"}}
</label>
<label>
<Input
@type="checkbox"
@checked={{parameter.enum}}
{{on "change" (fn this.toggleEnum parameter)}}
<input
{{on "input" (fn this.toggleEnum parameter)}}
checked={{parameter.enum}}
type="checkbox"
/>
{{I18n.t "discourse_ai.tools.parameter_enum"}}
</label>
<DButton
@icon="trash-alt"
@action={{fn this.removeParameter parameter}}
@icon="trash-alt"
class="btn-danger"
/>
</div>
{{#if parameter.enum}}
<div class="parameter-enum-values">
{{#each parameter.enumValues as |enumValue enumIndex|}}
<div class="enum-value-row">
<Input
@type="text"
@value={{enumValue}}
<input
{{on "change" (fn this.updateEnumValue parameter enumIndex)}}
value={{enumValue}}
type="text"
placeholder={{I18n.t "discourse_ai.tools.enum_value"}}
{{on
"input"
(fn
this.updateParameter
parameter.enumValues
enumIndex
value="target.value"
)
}}
/>
<DButton
@icon="trash-alt"
@action={{fn this.removeEnumValue parameter enumIndex}}
@icon="trash-alt"
class="btn-danger"
/>
</div>
{{/each}}
<DButton
@icon="plus"
@action={{fn this.addEnumValue parameter}}
@label="discourse_ai.tools.add_enum_value"
@icon="plus"
/>
</div>
{{/if}}
</div>
{{/each}}
<DButton
@icon="plus"
@action={{this.addParameter}}
@label="discourse_ai.tools.add_parameter"
@icon="plus"
/>
</template>
}

View File

@ -11,9 +11,9 @@ import I18n from "discourse-i18n";
import { jsonToHtml } from "../../lib/utilities";
export default class AiToolTestModal extends Component {
@tracked testResult = null;
@tracked testResult;
@tracked isLoading = false;
@tracked parameterValues = {};
parameterValues = {};
@action
updateParameter(name, event) {
@ -24,16 +24,14 @@ export default class AiToolTestModal extends Component {
async runTest() {
this.isLoading = true;
try {
const data = JSON.stringify({
ai_tool: this.args.model.tool,
parameters: this.parameterValues,
}); // required given this is a POST
const response = await ajax(
"/admin/plugins/discourse-ai/ai-tools/test.json",
{
type: "POST",
data,
data: JSON.stringify({
ai_tool: this.args.model.tool,
parameters: this.parameterValues,
}),
contentType: "application/json",
}
);
@ -49,20 +47,21 @@ export default class AiToolTestModal extends Component {
<DModal
@title={{I18n.t "discourse_ai.tools.test_modal.title"}}
@closeModal={{@closeModal}}
class="ai-tool-test-modal"
@bodyClass="ai-tool-test-modal__body"
class="ai-tool-test-modal"
>
<:body>
{{#each @model.tool.parameters as |param|}}
<div class="control-group">
<label>{{param.name}}</label>
<input
type="text"
name={{param.name}}
{{on "input" (fn this.updateParameter param.name)}}
name={{param.name}}
type="text"
/>
</div>
{{/each}}
{{#if this.testResult}}
<div class="ai-tool-test-modal__test-result">
<h3>{{I18n.t "discourse_ai.tools.test_modal.result"}}</h3>
@ -70,6 +69,7 @@ export default class AiToolTestModal extends Component {
</div>
{{/if}}
</:body>
<:footer>
<DButton
@action={{this.runTest}}

View File

@ -8,30 +8,27 @@ export function jsonToHtml(json) {
if (typeof json !== "object") {
return escapeExpression(json);
}
let html = "<ul>";
for (let key in json) {
if (!json.hasOwnProperty(key)) {
continue;
}
for (let [key, value] of Object.entries(json)) {
html += "<li>";
if (typeof json[key] === "object" && Array.isArray(json[key])) {
html += `<strong>${escapeExpression(key)}:</strong> ${jsonToHtml(
json[key]
)}`;
} else if (typeof json[key] === "object") {
html += `<strong>${escapeExpression(key)}:</strong> <ul><li>${jsonToHtml(
json[key]
)}</li></ul>`;
key = escapeExpression(key);
if (typeof value === "object" && Array.isArray(value)) {
html += `<strong>${key}:</strong> ${jsonToHtml(value)}`;
} else if (typeof value === "object") {
html += `<strong>${key}:</strong> <ul><li>${jsonToHtml(value)}</li></ul>`;
} else {
let value = json[key];
if (typeof value === "string") {
value = escapeExpression(value);
value = value.replace(/\n/g, "<br>");
value = escapeExpression(value).replace(/\n/g, "<br>");
}
html += `<strong>${escapeExpression(key)}:</strong> ${value}`;
html += `<strong>${key}:</strong> ${value}`;
}
html += "</li>";
}
html += "</ul>";
return htmlSafe(html);
}

View File

@ -200,6 +200,8 @@ en:
parameter_enum: "Enum"
parameter_name: "Parameter Name"
parameter_description: "Parameter Description"
enum_value: "Enum value"
add_enum_value: "Add enum value"
edit: "Edit"
test: "Run Test"
delete: "Delete"