#550 - Taxonomy suggestions

This commit is contained in:
Elio Struyf
2023-03-28 22:41:21 +02:00
parent d52f51fec7
commit 5336f3c9c2
13 changed files with 285 additions and 26 deletions
+2 -1
View File
@@ -20,7 +20,8 @@
- [#513](https://github.com/estruyf/vscode-front-matter/issues/513): Added support for external UI scripts to add custom HTML on the dashboard elements
- [#530](https://github.com/estruyf/vscode-front-matter/issues/530): Implementation of the Front Matter AI 🤖 powered by [mendable.ai](https://mendable.ai)
- [#537](https://github.com/estruyf/vscode-front-matter/issues/537): Allow to use the root path `/` as the public folder
- [#541](https://github.com/estruyf/vscode-front-matter/issues/541): Add title AI suggestions for GitHub sponsors
- [#541](https://github.com/estruyf/vscode-front-matter/issues/541): Added title AI suggestions for GitHub sponsors
- [#550](https://github.com/estruyf/vscode-front-matter/issues/550): Added taxonomy (tags/categories) AI suggestions for GitHub sponsors
### 🎨 Enhancements
+2 -2
View File
@@ -96,10 +96,10 @@
"configuration": {
"title": "Front Matter: use frontmatter.json for shared team settings",
"properties": {
"frontMatter.sponsors.ai.titleEnabled": {
"frontMatter.sponsors.ai.enabled": {
"type": "boolean",
"default": false,
"markdownDescription": "Specify if you want to enable AI suggestions for content titles. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.sponsors.ai.titleenabled)",
"markdownDescription": "Specify if you want to enable AI suggestions. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.sponsors.ai.enabled)",
"scope": "Sponsors"
},
"frontMatter.extensibility.scripts": {
+1 -1
View File
@@ -96,7 +96,7 @@ export const SETTING_GIT_SUBMODULE_BRANCH = 'git.submodule.branch';
/**
* Sponsors only settings
*/
export const SETTING_SPONSORS_AI_TITLE = 'sponsors.ai.titleEnabled';
export const SETTING_SPONSORS_AI_ENABLED = 'sponsors.ai.enabled';
/**
* @deprecated
+1 -1
View File
@@ -251,7 +251,7 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
<body>
<div id="app" data-isProd="${isProd}" data-environment="${
isBeta ? 'BETA' : 'main'
}" data-version="${version.usedVersion}" ></div>
}" data-version="${version.usedVersion}"></div>
${(scriptsToLoad || [])
.map((script) => {
+4
View File
@@ -1,3 +1,4 @@
import { SETTING_SPONSORS_AI_ENABLED } from './../constants/settings';
import { workspace } from 'vscode';
import { Extension, Settings } from '.';
import { Dashboard } from '../commands/Dashboard';
@@ -46,7 +47,10 @@ export class PanelSettings {
public static async get(): Promise<IPanelSettings> {
const gitActions = Settings.get<boolean>(SETTING_GIT_ENABLED);
const aiEnabled = Settings.get<boolean>(SETTING_SPONSORS_AI_ENABLED);
return {
aiEnabled: Settings.get<boolean>(SETTING_SPONSORS_AI_ENABLED) || false,
git: {
isGitRepo: gitActions ? await GitListener.isGitRepository() : false,
actions: gitActions || false
+3 -3
View File
@@ -1,6 +1,6 @@
import { authentication, QuickPickItem, QuickPickItemKind, window } from 'vscode';
import { Folders } from '../commands/Folders';
import { SETTING_SPONSORS_AI_TITLE } from '../constants';
import { SETTING_SPONSORS_AI_ENABLED } from '../constants';
import { ContentType } from './ContentType';
import { Notifications } from './Notifications';
import { Settings } from './SettingsHelper';
@@ -28,10 +28,10 @@ export class Questions {
* @returns
*/
public static async ContentTitle(showWarning: boolean = true): Promise<string | undefined> {
const aiContentTitle = Settings.get<boolean>(SETTING_SPONSORS_AI_TITLE);
const aiEnabled = Settings.get<boolean>(SETTING_SPONSORS_AI_ENABLED);
let title: string | undefined = '';
if (aiContentTitle) {
if (aiEnabled) {
const githubAuth = await authentication.getSession('github', ['read:user'], { silent: true });
if (githubAuth && githubAuth.account.label) {
+74 -3
View File
@@ -1,11 +1,20 @@
import { CommandToCode } from '../../panelWebView/CommandToCode';
import { TagType } from '../../panelWebView/TagType';
import { BaseListener } from './BaseListener';
import { window } from 'vscode';
import { ArticleHelper, Settings } from '../../helpers';
import { authentication, window } from 'vscode';
import { ArticleHelper, Extension, Settings } from '../../helpers';
import { BlockFieldData, CustomTaxonomyData, PostMessageData, TaxonomyType } from '../../models';
import { DataListener } from '.';
import { SETTING_TAXONOMY_CATEGORIES, SETTING_TAXONOMY_TAGS } from '../../constants';
import {
DefaultFields,
SETTING_SEO_DESCRIPTION_FIELD,
SETTING_SEO_TITLE_FIELD,
SETTING_TAXONOMY_CATEGORIES,
SETTING_TAXONOMY_TAGS
} from '../../constants';
import { SponsorAi } from '../../services/SponsorAI';
import { ExplorerView } from '../../explorerView/ExplorerView';
import { MessageHandlerData } from '@estruyf/vscode';
export class TaxonomyListener extends BaseListener {
/**
@@ -52,9 +61,71 @@ export class TaxonomyListener extends BaseListener {
case CommandToCode.addToCustomTaxonomy:
this.addCustomTaxonomy(msg.payload);
break;
case CommandToCode.aiSuggestTaxonomy:
this.aiSuggestTaxonomy(msg.command, msg.requestId, msg.payload);
break;
}
}
private static async aiSuggestTaxonomy(command: string, requestId?: string, type?: TagType) {
if (!command || !requestId || !type) {
return;
}
const extPath = Extension.getInstance().extensionPath;
const panel = ExplorerView.getInstance(extPath);
const editor = window.activeTextEditor;
if (!editor) {
panel.getWebview()?.postMessage({
command,
requestId,
error: 'No active editor'
} as MessageHandlerData<string>);
return;
}
const article = ArticleHelper.getFrontMatter(editor);
if (!article || !article.data) {
panel.getWebview()?.postMessage({
command,
requestId,
error: 'No article data'
} as MessageHandlerData<string>);
return;
}
const githubAuth = await authentication.getSession('github', ['read:user'], { silent: true });
if (!githubAuth || !githubAuth.accessToken) {
return;
}
const titleField = (Settings.get(SETTING_SEO_TITLE_FIELD) as string) || DefaultFields.Title;
const descriptionField =
(Settings.get(SETTING_SEO_DESCRIPTION_FIELD) as string) || DefaultFields.Description;
const suggestions = await SponsorAi.getTaxonomySuggestions(
githubAuth.accessToken,
article.data[titleField] || '',
article.data[descriptionField] || '',
type
);
if (!suggestions) {
panel.getWebview()?.postMessage({
command,
requestId,
error: 'No article data'
} as MessageHandlerData<string>);
}
panel.getWebview()?.postMessage({
command,
requestId,
payload: suggestions || []
} as MessageHandlerData<string[]>);
}
/**
* Update the tags in the current document
* @param tagType
+1
View File
@@ -28,6 +28,7 @@ export interface PanelSettings {
dataTypes: DataType[] | undefined;
fieldGroups: FieldGroup[] | undefined;
commaSeparatedFields: string[];
aiEnabled: boolean;
}
export interface FieldGroup {
+2 -1
View File
@@ -39,5 +39,6 @@ export enum CommandToCode {
setContentType = 'set-content-type',
getDataEntries = 'get-data-entries',
generateSlug = 'generate-slug',
stopServer = 'stop-server'
stopServer = 'stop-server',
aiSuggestTaxonomy = 'ai-suggest-taxonomy'
}
@@ -8,13 +8,15 @@ export interface IFieldTitleProps {
icon?: JSX.Element;
className?: string;
required?: boolean;
actionElement?: JSX.Element;
}
export const FieldTitle: React.FunctionComponent<IFieldTitleProps> = ({
label,
icon,
className,
required
required,
actionElement,
}: React.PropsWithChildren<IFieldTitleProps>) => {
const Icon = useMemo(() => {
return icon ? React.cloneElement(icon, { style: { width: '16px', height: '16px' } }) : null;
@@ -23,9 +25,13 @@ export const FieldTitle: React.FunctionComponent<IFieldTitleProps> = ({
return (
<VsLabel>
<div className={`metadata_field__label ${className || ''}`}>
{Icon}
<span style={{ lineHeight: '16px' }}>{label}</span>
<RequiredAsterix required={required} />
<div>
{Icon}
<span style={{ lineHeight: '16px' }}>{label}</span>
<RequiredAsterix required={required} />
</div>
{actionElement}
</div>
</VsLabel>
);
+54 -8
View File
@@ -7,9 +7,12 @@ import Downshift from 'downshift';
import { AddIcon } from './Icons/AddIcon';
import { BlockFieldData, CustomTaxonomyData } from '../../models';
import { useCallback, useEffect, useMemo } from 'react';
import { Messenger } from '@estruyf/vscode/dist/client';
import { messageHandler, Messenger } from '@estruyf/vscode/dist/client';
import { FieldMessage } from './Fields/FieldMessage';
import { FieldTitle } from './Fields/FieldTitle';
import { useRecoilValue } from 'recoil';
import { PanelSettingsAtom } from '../state';
import { SparklesIcon } from '@heroicons/react/outline';
export interface ITagPickerProps {
type: TagType;
@@ -54,6 +57,8 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
const prevSelected = usePrevious(crntSelected);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const dsRef = React.useRef<Downshift<string> | null>(null);
const settings = useRecoilValue(PanelSettingsAtom);
const [loading, setLoading] = React.useState<boolean>(false);
/**
* Removes an option
@@ -211,6 +216,19 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
[options, inputRef, selected, freeform]
);
const suggestTaxonomy = useCallback((type: TagType) => {
setLoading(true);
messageHandler.request<string[]>(CommandToCode.aiSuggestTaxonomy, type).then((values) => {
setLoading(false);
if (values && values instanceof Array && values.length > 0) {
const uniqValues = Array.from(new Set([...selected, ...values]));
setSelected(uniqValues);
sendUpdate(uniqValues);
setInputValue('');
}
});
}, [selected]);
/**
* Check if the input is disabled
*/
@@ -234,6 +252,27 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
return required && (selected || []).length === 0;
}, [required, selected]);
const actionElement = useMemo(() => {
if (!settings?.aiEnabled) {
return;
}
if (type !== TagType.tags && type !== TagType.categories) {
return;
}
return (
<button
className='metadata_field__title__action'
title={`Use Front Matter AI to suggest ${label?.toLowerCase() || type.toLowerCase()}`}
type='button'
onClick={() => suggestTaxonomy(type)}
disabled={loading}>
<SparklesIcon />
</button>
);
}, [settings?.aiEnabled, label, type]);
useEffect(() => {
setTimeout(() => {
triggerFocus();
@@ -248,6 +287,13 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
return (
<div className={`article__tags`}>
{
loading && (
<div className='metadata_field__loading'>
Generating suggestions...
</div>
)
}
<FieldTitle
label={
<>
@@ -262,6 +308,7 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
)}
</>
}
actionElement={actionElement}
icon={icon}
required={required}
/>
@@ -288,9 +335,8 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
<>
<div
{...getRootProps(undefined, { suppressRefError: true })}
className={`article__tags__input ${freeform ? 'freeform' : ''} ${
showRequiredState ? 'required' : ''
}`}
className={`article__tags__input ${freeform ? 'freeform' : ''} ${showRequiredState ? 'required' : ''
}`}
>
<input
{...getInputProps({
@@ -328,10 +374,10 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
>
{isOpen
? options
.filter((option) => filterList(option, inputValue))
.map((item, index) => (
<li {...getItemProps({ key: item, index, item })}>{item}</li>
))
.filter((option) => filterList(option, inputValue))
.map((item, index) => (
<li {...getItemProps({ key: item, index, item })}>{item}</li>
))
: null}
</ul>
</>
+55
View File
@@ -291,7 +291,55 @@ button {
.metadata_field__label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
div {
display: flex;
align-items: center;
}
button {
all: unset;
}
.metadata_field__title__action {
display: inline-flex;
justify-content: center;
height: 16px;
width: 16px;
&:hover {
color: var(--vscode-button-hoverBackground);
fill: var(--vscode-button-hoverBackground);
cursor: pointer;
}
&:disabled {
opacity: 0.5;
color: var(--vscode-disabledForeground);
}
svg {
margin-right: 0;
}
}
}
.metadata_field__loading {
border-radius: 0.25rem;
backdrop-filter: blur(15px);
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: calc(100% + 2.5em);
background-color: rgba(0, 0, 0, 0.8);
top: 30px;
left: -1.25rem;
right: 0;
bottom: 0;
z-index: 1;
}
.metadata_field__label.metadata_field__label_parent {
@@ -326,6 +374,7 @@ button {
.metadata_field__textarea,
.metadata_field__textarea:focus {
outline: none;
border-radius: 0.25rem;
}
/* Description message */
@@ -714,10 +763,15 @@ vscode-divider {
}
/* Tags */
.article__tags {
position: relative;
}
.article__tags__input {
position: relative;
outline: 1px solid var(--vscode-inputValidation-infoBorder);
outline-offset: -1px;
border-radius: 0.25rem;
&.required {
outline: 1px solid var(--vscode-inputValidation-errorBorder);
@@ -831,6 +885,7 @@ vscode-divider {
padding-right: 2.5rem;
border: 1px solid var(--vscode-inputValidation-infoBorder);
outline: none;
border-radius: 0.25rem;
&:disabled {
color: var(--vscode-disabledForeground);
+76 -2
View File
@@ -1,8 +1,22 @@
import { SETTING_SEO_TITLE_LENGTH } from '../constants';
import {
SETTING_SEO_TITLE_LENGTH,
SETTING_TAXONOMY_CATEGORIES,
SETTING_TAXONOMY_TAGS
} from '../constants';
import { Logger, Notifications, Settings } from '../helpers';
import fetch from 'node-fetch';
import { TagType } from '../panelWebView/TagType';
const AI_URL = 'https://frontmatter.codes/api/ai';
// const AI_URL = 'http://localhost:3000/api/ai';
export class SponsorAi {
/**
* Get title suggestions from the AI
* @param token
* @param title
* @returns
*/
public static async getTitles(token: string, title: string) {
try {
const controller = new AbortController();
@@ -12,7 +26,7 @@ export class SponsorAi {
}, 10000);
const signal = controller.signal;
const response = await fetch(`https://frontmatter.codes/api/ai-title`, {
const response = await fetch(`${AI_URL}/title`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -35,4 +49,64 @@ export class SponsorAi {
return undefined;
}
}
/**
* Get taxonomy suggestions from the AI
* @param token
* @param title
* @param description
* @param type
* @returns
*/
public static async getTaxonomySuggestions(
token: string,
title: string,
description: string,
type: TagType
) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => {
Notifications.warning(`The AI taxonomy generation took too long. Please try again later.`);
controller.abort();
}, 10000);
const signal = controller.signal;
let options =
type === TagType.tags
? Settings.get<string[]>(SETTING_TAXONOMY_TAGS, true)
: Settings.get<string[]>(SETTING_TAXONOMY_CATEGORIES, true);
const optionsString = options?.join(',') || '';
const body = JSON.stringify({
title: title,
description: description,
token: token,
type: type,
taxonomy: optionsString
});
const response = await fetch(`${AI_URL}/taxonomy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
accept: 'application/json'
},
body,
signal: signal as any
});
clearTimeout(timeout);
if (!response.ok) {
return undefined;
}
const data: string[] = await response.json();
return data || [];
} catch (e) {
Logger.error(`Sponsor AI: ${(e as Error).message}`);
return undefined;
}
}
}