mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-07-06 01:41:48 +02:00
#550 - Taxonomy suggestions
This commit is contained in:
+2
-1
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface PanelSettings {
|
||||
dataTypes: DataType[] | undefined;
|
||||
fieldGroups: FieldGroup[] | undefined;
|
||||
commaSeparatedFields: string[];
|
||||
aiEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface FieldGroup {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user