diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3a9b86..89d27de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,15 @@ ## [8.5.0] - 2023-xx-xx +### 🧪 Experimental features + +- External UI script support for dashboards +- Front matter AI 🤖 + ### ✨ New features +- Added description AI suggestion for GitHub sponsors +- The Visual Studio Code theme support is now released in the stable version - [#424](https://github.com/estruyf/vscode-front-matter/issues/424): Snippet wrapping to allow easier updates or changes to previously set snippets in the content - [#585](https://github.com/estruyf/vscode-front-matter/issues/585): New content relationship field type (`contentRelationship`) diff --git a/src/dashboardWebView/hooks/useThemeColors.tsx b/src/dashboardWebView/hooks/useThemeColors.tsx index 491f8205..7a4fe2af 100644 --- a/src/dashboardWebView/hooks/useThemeColors.tsx +++ b/src/dashboardWebView/hooks/useThemeColors.tsx @@ -7,11 +7,8 @@ export default function useThemeColors() { const { experimental } = useSettingsContext(); const getColors = useCallback((defaultColors: string, themeColors: string) => { - if (experimental) { - return themeColors; - } - - return defaultColors; + // The feature is now enabled by default + return themeColors; }, [experimental]); return { diff --git a/src/dashboardWebView/index.tsx b/src/dashboardWebView/index.tsx index 1c9c91c9..b793a356 100644 --- a/src/dashboardWebView/index.tsx +++ b/src/dashboardWebView/index.tsx @@ -107,10 +107,8 @@ if (elm) { const url = elm?.getAttribute('data-url'); const experimental = elm?.getAttribute('data-experimental'); - if (experimental) { - updateCssVariables(); - mutationObserver.observe(document.body, { childList: false, attributes: true }); - } + updateCssVariables(); + mutationObserver.observe(document.body, { childList: false, attributes: true }); if (isProd === 'true') { Sentry.init({ @@ -127,9 +125,7 @@ if (elm) { }); } - if (experimental) { - elm.setAttribute("class", "experimental bg-[var(--vscode-editor-background)] text-[var(--vscode-editor-foreground)]"); - } + elm.setAttribute("class", `${experimental ? "experimental" : ""} bg-[var(--vscode-editor-background)] text-[var(--vscode-editor-foreground)]`); if (type === 'preview') { render( diff --git a/src/listeners/panel/DataListener.ts b/src/listeners/panel/DataListener.ts index a5567598..902e47e9 100644 --- a/src/listeners/panel/DataListener.ts +++ b/src/listeners/panel/DataListener.ts @@ -5,13 +5,14 @@ import { Folders } from '../../commands/Folders'; import { Command } from '../../panelWebView/Command'; import { CommandToCode } from '../../panelWebView/CommandToCode'; import { BaseListener } from './BaseListener'; -import { commands, ThemeIcon, window } from 'vscode'; -import { ArticleHelper, ContentType, Logger, Settings } from '../../helpers'; +import { authentication, commands, ThemeIcon, window } from 'vscode'; +import { ArticleHelper, ContentType, Extension, Logger, Settings } from '../../helpers'; import { COMMAND_NAME, DefaultFields, SETTING_COMMA_SEPARATED_FIELDS, SETTING_DATE_FORMAT, + SETTING_SEO_TITLE_FIELD, SETTING_TAXONOMY_CONTENT_TYPES } from '../../constants'; import { Article, Preview } from '../../commands'; @@ -19,6 +20,9 @@ import { ParsedFrontMatter } from '../../parsers'; import { processKnownPlaceholders } from '../../helpers/PlaceholderHelper'; import { Field, PostMessageData } from '../../models'; import { encodeEmoji } from '../../utils'; +import { ExplorerView } from '../../explorerView/ExplorerView'; +import { MessageHandlerData } from '@estruyf/vscode'; +import { SponsorAi } from '../../services/SponsorAI'; const FILE_LIMIT = 10; @@ -68,9 +72,71 @@ export class DataListener extends BaseListener { case CommandToCode.getDataEntries: this.getDataFileEntries(msg.command, msg.requestId || '', msg.payload); break; + case CommandToCode.aiSuggestDescription: + this.aiSuggestTaxonomy(msg.command, msg.requestId); + break; } } + private static async aiSuggestTaxonomy(command: string, requestId?: string) { + if (!command || !requestId) { + 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); + return; + } + + const article = ArticleHelper.getFrontMatter(editor); + if (!article || !article.data) { + panel.getWebview()?.postMessage({ + command, + requestId, + error: 'No article data' + } as MessageHandlerData); + 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 suggestion = await SponsorAi.getDescription( + githubAuth.accessToken, + article.data[titleField] || '', + article.content || '' + ); + + console.log(suggestion); + + if (!suggestion) { + panel.getWebview()?.postMessage({ + command, + requestId, + error: 'No article data' + } as MessageHandlerData); + return; + } + + panel.getWebview()?.postMessage({ + command, + requestId, + payload: suggestion || [] + } as MessageHandlerData); + } + /** * Retrieve the information about the registered folders and its files */ diff --git a/src/listeners/panel/TaxonomyListener.ts b/src/listeners/panel/TaxonomyListener.ts index 208a6268..24d768e4 100644 --- a/src/listeners/panel/TaxonomyListener.ts +++ b/src/listeners/panel/TaxonomyListener.ts @@ -115,6 +115,7 @@ export class TaxonomyListener extends BaseListener { requestId, error: 'No article data' } as MessageHandlerData); + return; } panel.getWebview()?.postMessage({ diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index 6e654e98..4896c660 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -41,6 +41,7 @@ export enum CommandToCode { generateSlug = 'generate-slug', stopServer = 'stop-server', aiSuggestTaxonomy = 'ai-suggest-taxonomy', + aiSuggestDescription = 'ai-suggest-description', searchByType = 'search-by-type', processMediaData = 'process-media-data' } diff --git a/src/panelWebView/components/Fields/TextField.tsx b/src/panelWebView/components/Fields/TextField.tsx index 04ade9c5..601b7f95 100644 --- a/src/panelWebView/components/Fields/TextField.tsx +++ b/src/panelWebView/components/Fields/TextField.tsx @@ -1,17 +1,21 @@ -import { PencilIcon } from '@heroicons/react/outline'; +import { PencilIcon, SparklesIcon } from '@heroicons/react/outline'; import * as React from 'react'; import { useCallback, useEffect, useMemo } from 'react'; import { useRecoilState } from 'recoil'; -import { BaseFieldProps } from '../../../models'; +import { BaseFieldProps, PanelSettings } from '../../../models'; import { RequiredFieldsAtom } from '../../state'; import { FieldTitle } from './FieldTitle'; import { FieldMessage } from './FieldMessage'; +import { messageHandler } from '@estruyf/vscode/dist/client'; +import { CommandToCode } from '../../CommandToCode'; export interface ITextFieldProps extends BaseFieldProps { singleLine: boolean | undefined; wysiwyg: boolean | undefined; limit: number | undefined; rows?: number; + name: string; + settings: PanelSettings; onChange: (txtValue: string) => void; } @@ -25,11 +29,14 @@ export const TextField: React.FunctionComponent = ({ description, value, rows, + name, + settings, onChange, required }: React.PropsWithChildren) => { const [, setRequiredFields] = useRecoilState(RequiredFieldsAtom); const [text, setText] = React.useState(value); + const [loading, setLoading] = React.useState(false); const onTextChange = (txtValue: string) => { setText(txtValue); @@ -75,6 +82,37 @@ export const TextField: React.FunctionComponent = ({ } }, [showRequiredState, isValid]); + const suggestDescription = () => { + setLoading(true); + messageHandler.request(CommandToCode.aiSuggestDescription).then((suggestion) => { + setLoading(false); + + if (suggestion) { + setText(suggestion); + onChange(suggestion); + } + }).catch(() => { + setLoading(false); + }); + }; + + const actionElement = useMemo(() => { + if (!settings?.aiEnabled || settings.seo.descriptionField !== name) { + return; + } + + return ( + + ); + }, [settings?.aiEnabled, name]); + useEffect(() => { if (text !== value) { setText(value); @@ -83,7 +121,15 @@ export const TextField: React.FunctionComponent = ({ return (
- } required={required} /> + { + loading && ( +
+ Generating suggestion... +
+ ) + } + + } required={required} /> {wysiwyg ? ( Loading field
}> diff --git a/src/panelWebView/components/Fields/WrapperField.tsx b/src/panelWebView/components/Fields/WrapperField.tsx index 13f6a0a5..dd6cdd09 100644 --- a/src/panelWebView/components/Fields/WrapperField.tsx +++ b/src/panelWebView/components/Fields/WrapperField.tsx @@ -209,6 +209,7 @@ export const WrapperField: React.FunctionComponent = ({ return ( = ({ onChange={(value) => onSendUpdate(field.name, value, parentFields)} value={(fieldValue as string) || null} required={!!field.required} + settings={settings} /> ); diff --git a/src/panelWebView/components/TagPicker.tsx b/src/panelWebView/components/TagPicker.tsx index 2a5f775d..3157afa5 100644 --- a/src/panelWebView/components/TagPicker.tsx +++ b/src/panelWebView/components/TagPicker.tsx @@ -226,6 +226,8 @@ const TagPicker: React.FunctionComponent = ({ sendUpdate(uniqValues); setInputValue(''); } + }).catch(() => { + setLoading(false); }); }, [selected]); diff --git a/src/panelWebView/styles.css b/src/panelWebView/styles.css index d6f8d45d..d1203b9a 100644 --- a/src/panelWebView/styles.css +++ b/src/panelWebView/styles.css @@ -286,6 +286,7 @@ button { /* Metadata section - Content type */ .metadata_field { margin-bottom: 1rem; + position: relative; } .metadata_field__label { diff --git a/src/services/SponsorAI.ts b/src/services/SponsorAI.ts index 0c915d37..bd8b4b35 100644 --- a/src/services/SponsorAI.ts +++ b/src/services/SponsorAI.ts @@ -1,4 +1,4 @@ -import { SETTING_SEO_TITLE_LENGTH } from '../constants'; +import { SETTING_SEO_DESCRIPTION_LENGTH, SETTING_SEO_TITLE_LENGTH } from '../constants'; import { Logger, Notifications, Settings, TaxonomyHelper } from '../helpers'; import fetch from 'node-fetch'; import { TagType } from '../panelWebView/TagType'; @@ -47,6 +47,45 @@ export class SponsorAi { } } + public static async getDescription(token: string, title: string, content: string) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => { + Notifications.warning(`The AI title generation took too long. Please try again later.`); + controller.abort(); + }, 10000); + const signal = controller.signal; + + let articleContent = content; + if (articleContent.length > 2000) { + articleContent = articleContent.substring(0, 2000); + } + + const response = await fetch(`${AI_URL}/description`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + accept: 'application/json' + }, + body: JSON.stringify({ + title: title, + content: articleContent, + token: token, + nrOfCharacters: Settings.get(SETTING_SEO_DESCRIPTION_LENGTH) || 160 + }), + signal: signal as any + }); + clearTimeout(timeout); + + const data: string = await response.text(); + + return data || ''; + } catch (e) { + Logger.error(`Sponsor AI: ${(e as Error).message}`); + return undefined; + } + } + /** * Get taxonomy suggestions from the AI * @param token