#823 - Support titles and taxonomy

This commit is contained in:
Elio Struyf
2024-06-26 11:35:02 +02:00
parent e77de75333
commit 3a4e9fd8ff
14 changed files with 675 additions and 340 deletions

View File

@@ -522,6 +522,7 @@
"panel.tagPicker.inputPlaceholder.empty": "Pick your {0}",
"panel.tagPicker.inputPlaceholder.disabled": "You have reached the limit of {0}",
"panel.tagPicker.ai.suggest": "Use Front Matter AI to suggest {0}",
"panel.tagPicker.copilot.suggest": "Use GitHub Copilot to suggest {0}",
"panel.tagPicker.ai.generating": "Generating suggestions...",
"panel.tagPicker.limit": "Max.: {0}",
"panel.tagPicker.unkown": "Add the unknown tag",
@@ -705,9 +706,11 @@
"helpers.questions.contentTitle.aiInput.placeholder": "What would you like to write about?",
"helpers.questions.contentTitle.aiInput.quickPick.title.separator": "your title/description",
"helpers.questions.contentTitle.aiInput.quickPick.ai.separator": "AI generated title",
"helpers.questions.contentTitle.aiInput.quickPick.copilot.separator": "GitHub Copilot suggestions",
"helpers.questions.contentTitle.aiInput.select.title": "Select a title",
"helpers.questions.contentTitle.aiInput.select.placeholder": "Select a title for your content",
"helpers.questions.contentTitle.aiInput.failed": "Failed fetching the AI title. Please try to use your own title or try again later.",
"helpers.questions.contentTitle.copilotInput.failed": "Failed fetching the GitHub Copilot title suggestions. Please try to use your own title or try again later.",
"helpers.questions.contentTitle.aiInput.warning": "You did not specify a title for your content.",
"helpers.questions.contentTitle.titleInput.title": "Content title",
"helpers.questions.contentTitle.titleInput.prompt": "What would you like to use as a title for the content to create?",
@@ -781,6 +784,8 @@
"listeners.panel.taxonomyListener.aiSuggestTaxonomy.noEditor.error": "No active editor",
"listeners.panel.taxonomyListener.aiSuggestTaxonomy.noData.error": "No article data",
"services.copilot.getChatResponse.error": "Failed to get a response from the GitHub Copilot.",
"services.modeSwitch.switchMode.quickPick.placeholder": "Select the mode you want to use",
"services.modeSwitch.switchMode.quickPick.title": "{0}: Mode selection",
"services.modeSwitch.setText.mode": "Mode: {0}",

View File

@@ -31,6 +31,7 @@
},
"l10n": "./l10n",
"categories": [
"AI",
"Other"
],
"keywords": [

View File

@@ -9,6 +9,7 @@ import { SponsorAi } from '../services/SponsorAI';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../localization';
import { ContentFolder } from '../models';
import { Copilot } from '../services/Copilot';
interface FolderQuickPickItem extends QuickPickItem {
path: string;
@@ -41,11 +42,12 @@ export class Questions {
public static async ContentTitle(showWarning: boolean = true): Promise<string | undefined> {
const aiEnabled = Settings.get<boolean>(SETTING_SPONSORS_AI_ENABLED);
let title: string | undefined = '';
const isCopilotInstalled = await Copilot.isInstalled();
if (aiEnabled) {
const githubAuth = await authentication.getSession('github', ['read:user'], { silent: true });
let aiTitles: string[] | undefined;
if (githubAuth && githubAuth.account.label) {
if (aiEnabled || isCopilotInstalled) {
if (isCopilotInstalled) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
@@ -55,54 +57,81 @@ export class Questions {
if (title) {
try {
const aiTitles = await SponsorAi.getTitles(githubAuth.accessToken, title);
if (aiTitles && aiTitles.length > 0) {
const options: QuickPickItem[] = [
{
label: `✏️ ${l10n.t(
LocalizationKey.helpersQuestionsContentTitleAiInputQuickPickTitleSeparator
)}`,
kind: QuickPickItemKind.Separator
},
{
label: title
},
{
label: `🤖 ${l10n.t(
LocalizationKey.helpersQuestionsContentTitleAiInputQuickPickAiSeparator
)}`,
kind: QuickPickItemKind.Separator
},
...aiTitles.map((d: string) => ({
label: d
}))
];
const selectedTitle = await window.showQuickPick(options, {
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputSelectTitle),
placeHolder: l10n.t(
LocalizationKey.helpersQuestionsContentTitleAiInputSelectPlaceholder
),
ignoreFocusOut: true
});
if (selectedTitle) {
title = selectedTitle.label;
} else if (!selectedTitle) {
// Reset the title, so the user can enter their own title
title = undefined;
}
}
aiTitles = await Copilot.suggestTitles(title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputFailed));
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleCopilotInputFailed)
);
title = undefined;
}
} else if (!title && showWarning) {
Notifications.warning(l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputWarning));
return;
}
} else {
const githubAuth = await authentication.getSession('github', ['read:user'], {
silent: true
});
if (githubAuth && githubAuth.account.label) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPlaceholder),
ignoreFocusOut: true
});
if (title) {
try {
aiTitles = await SponsorAi.getTitles(githubAuth.accessToken, title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputFailed)
);
title = undefined;
}
}
}
}
if (title && aiTitles && aiTitles.length > 0) {
const options: QuickPickItem[] = [
{
label: `✏️ ${l10n.t(
LocalizationKey.helpersQuestionsContentTitleAiInputQuickPickTitleSeparator
)}`,
kind: QuickPickItemKind.Separator
},
{
label: title
},
{
label: `🤖 ${l10n.t(
isCopilotInstalled
? LocalizationKey.helpersQuestionsContentTitleAiInputQuickPickCopilotSeparator
: LocalizationKey.helpersQuestionsContentTitleAiInputQuickPickAiSeparator
)}`,
kind: QuickPickItemKind.Separator
},
...aiTitles.map((d: string) => ({
label: d
}))
];
const selectedTitle = await window.showQuickPick(options, {
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputSelectTitle),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputSelectPlaceholder),
ignoreFocusOut: true
});
if (selectedTitle) {
title = selectedTitle.label;
} else if (!selectedTitle) {
// Reset the title, so the user can enter their own title
title = undefined;
}
} else if (!title && showWarning) {
Notifications.warning(l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputWarning));
return;
}
}

View File

@@ -6,13 +6,9 @@ import { Command } from '../../panelWebView/Command';
import { CommandToCode } from '../../panelWebView/CommandToCode';
import { BaseListener } from './BaseListener';
import {
CancellationTokenSource,
LanguageModelChatMessage,
LanguageModelChatResponse,
Uri,
authentication,
commands,
lm,
window
} from 'vscode';
import {
@@ -34,7 +30,6 @@ import {
SETTING_DATE_FORMAT,
SETTING_GLOBAL_ACTIVE_MODE,
SETTING_GLOBAL_MODES,
SETTING_SEO_DESCRIPTION_LENGTH,
SETTING_SEO_TITLE_FIELD,
SETTING_TAXONOMY_CONTENT_TYPES
} from '../../constants';
@@ -55,6 +50,7 @@ import { Terminal } from '../../services';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../localization';
import { parse } from 'path';
import { Copilot } from '../../services/Copilot';
const FILE_LIMIT = 10;
@@ -114,13 +110,19 @@ export class DataListener extends BaseListener {
case CommandToCode.aiSuggestDescription:
this.aiSuggestTaxonomy(msg.command, msg.requestId);
break;
case CommandToCode.copilotDescription:
this.copilotSuggestion(msg.command, msg.requestId);
case CommandToCode.copilotSuggestDescription:
this.copilotSuggestDescription(msg.command, msg.requestId);
break;
}
}
private static async copilotSuggestion(command: string, requestId?: string) {
/**
* Executes a Copilot suggestion command.
* @param command - The command to execute.
* @param requestId - The optional request ID.
* @returns A Promise that resolves when the suggestion command is executed.
*/
private static async copilotSuggestDescription(command: string, requestId?: string) {
if (!command || !requestId) {
return;
}
@@ -138,63 +140,32 @@ export class DataListener extends BaseListener {
const extPath = Extension.getInstance().extensionPath;
const panel = PanelProvider.getInstance(extPath);
const [model] = await lm.selectChatModels({
vendor: 'copilot',
// family: 'gpt-4'
});
const titleField = (Settings.get(SETTING_SEO_TITLE_FIELD) as string) || DefaultFields.Title;
const description = await Copilot.suggestDescription(
articleDetails.data[titleField],
articleDetails.content
);
// TODO: Create settings for: copilot.description.message, copilot.family,
const chars = Settings.get<number>(SETTING_SEO_DESCRIPTION_LENGTH) || 160;
const messages = [
LanguageModelChatMessage.User(
`You are a CMS expert for Front Matter CMS and your task is to assist the user to generate a SEO friendly abstract/description for their article. When the user provides a title and/or content, you should use this information to generate the description.
IMPORTANT: You are only allowed to respond with a text that should not exceed ${chars} characters in length.`
)
];
if (articleDetails && articleDetails.data?.title) {
messages.push(LanguageModelChatMessage.User(
`The title of the blog post is """${articleDetails.data.title}""".`
));
}
if (articleDetails && articleDetails.content) {
messages.push(LanguageModelChatMessage.User(
`The content of the blog post is: """${articleDetails.content}""".`
));
}
let chatResponse: LanguageModelChatResponse | undefined;
try {
chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token);
} catch (err) {
Logger.error(`DataListener:copilotSuggestion:: ${(err as Error).message}`);
if (description) {
panel.getWebview()?.postMessage({
command,
requestId,
error: l10n.t(LocalizationKey.listenersPanelDataListenerAiSuggestTaxonomyNoDataError)
payload: description
} as MessageHandlerData<string>);
return;
}
let allFragments = [];
for await (const fragment of chatResponse.text) {
allFragments.push(fragment);
}
if (allFragments.length > 0) {
const description = allFragments.join('');
} else {
panel.getWebview()?.postMessage({
command,
requestId,
payload: description.trim()
error: l10n.t(LocalizationKey.servicesCopilotGetChatResponseError)
} as MessageHandlerData<string>);
}
}
/**
* Suggests taxonomy using AI.
* @param command - The command string.
* @param requestId - The optional request ID.
*/
private static async aiSuggestTaxonomy(command: string, requestId?: string) {
if (!command || !requestId) {
return;

View File

@@ -15,6 +15,7 @@ import { PanelProvider } from '../../panelWebView/PanelProvider';
import { MessageHandlerData } from '@estruyf/vscode';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../localization';
import { Copilot } from '../../services/Copilot';
export class TaxonomyListener extends BaseListener {
/**
@@ -66,9 +67,72 @@ export class TaxonomyListener extends BaseListener {
case CommandToCode.aiSuggestTaxonomy:
this.aiSuggestTaxonomy(msg.command, msg.requestId, msg.payload);
break;
case CommandToCode.copilotSuggestTaxonomy:
this.copilotSuggestTaxonomy(msg.command, msg.requestId, msg.payload);
break;
}
}
/**
* Suggests a taxonomy for a given command, request ID, and tag type.
*
* @param command - The command to execute.
* @param requestId - The ID of the request.
* @param type - The type of the tag.
* @returns A Promise that resolves to void.
*/
private static async copilotSuggestTaxonomy(command: string, requestId?: string, type?: TagType) {
if (!command || !requestId || !type) {
return;
}
const article = ArticleHelper.getActiveFile();
if (!article) {
return;
}
const articleDetails = await ArticleHelper.getFrontMatterByPath(article);
if (!articleDetails) {
return;
}
const extPath = Extension.getInstance().extensionPath;
const panel = PanelProvider.getInstance(extPath);
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 tags = await Copilot.suggestTaxonomy(
articleDetails.data[titleField],
type,
articleDetails.data[descriptionField],
articleDetails.content
);
if (tags) {
panel.getWebview()?.postMessage({
command,
requestId,
payload: tags
} as MessageHandlerData<string[]>);
} else {
panel.getWebview()?.postMessage({
command,
requestId,
error: l10n.t(LocalizationKey.servicesCopilotGetChatResponseError)
} as MessageHandlerData<string>);
}
}
/**
* Suggests taxonomy based on the provided command, request ID, and tag type.
*
* @param command - The command to execute.
* @param requestId - The ID of the request.
* @param type - The type of tag.
* @returns A Promise that resolves to void.
*/
private static async aiSuggestTaxonomy(command: string, requestId?: string, type?: TagType) {
if (!command || !requestId || !type) {
return;

View File

@@ -1680,6 +1680,10 @@ export enum LocalizationKey {
* Use Front Matter AI to suggest {0}
*/
panelTagPickerAiSuggest = 'panel.tagPicker.ai.suggest',
/**
* Use GitHub Copilot to suggest {0}
*/
panelTagPickerCopilotSuggest = 'panel.tagPicker.copilot.suggest',
/**
* Generating suggestions...
*/
@@ -2316,6 +2320,10 @@ export enum LocalizationKey {
* AI generated title
*/
helpersQuestionsContentTitleAiInputQuickPickAiSeparator = 'helpers.questions.contentTitle.aiInput.quickPick.ai.separator',
/**
* GitHub Copilot generated title
*/
helpersQuestionsContentTitleAiInputQuickPickCopilotSeparator = 'helpers.questions.contentTitle.aiInput.quickPick.copilot.separator',
/**
* Select a title
*/
@@ -2328,6 +2336,10 @@ export enum LocalizationKey {
* Failed fetching the AI title. Please try to use your own title or try again later.
*/
helpersQuestionsContentTitleAiInputFailed = 'helpers.questions.contentTitle.aiInput.failed',
/**
* Failed fetching the GitHub Copilot title suggestions. Please try to use your own title or try again later.
*/
helpersQuestionsContentTitleCopilotInputFailed = 'helpers.questions.contentTitle.copilotInput.failed',
/**
* You did not specify a title for your content.
*/
@@ -2576,6 +2588,10 @@ export enum LocalizationKey {
* No article data
*/
listenersPanelTaxonomyListenerAiSuggestTaxonomyNoDataError = 'listeners.panel.taxonomyListener.aiSuggestTaxonomy.noData.error',
/**
* Failed to get a response from the GitHub Copilot.
*/
servicesCopilotGetChatResponseError = 'services.copilot.getChatResponse.error',
/**
* Select the mode you want to use
*/

View File

@@ -42,7 +42,8 @@ export enum CommandToCode {
stopServer = 'stop-server',
aiSuggestTaxonomy = 'ai-suggest-taxonomy',
aiSuggestDescription = 'ai-suggest-description',
copilotDescription = 'copilot-suggest-description',
copilotSuggestDescription = 'copilot-suggest-description',
copilotSuggestTaxonomy = 'copilot-suggest-taxonomy',
searchByType = 'search-by-type',
processMediaData = 'process-media-data',
isServerStarted = 'is-server-started'

View File

@@ -22,8 +22,8 @@ export const FieldTitle: React.FunctionComponent<IFieldTitleProps> = ({
}, [icon]);
return (
<div className='flex items-center justify-between w-full'>
<label className={`metadata_field__label text-base text-[var(--vscode-foreground)] py-2 ${className || ''}`}>
<div className='flex items-center justify-between w-full mb-2'>
<label className={`metadata_field__label text-base text-[var(--vscode-foreground)] ${className || ''}`}>
{Icon}
<span style={{ lineHeight: '16px' }}>{label}</span>
<RequiredAsterix required={required} />

View File

@@ -16,6 +16,7 @@ import { SparklesIcon } from '@heroicons/react/24/outline';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import useDropdownStyle from '../../hooks/useDropdownStyle';
import { CopilotIcon } from '../Icons';
export interface ITagPickerProps {
type: TagType;
@@ -98,39 +99,42 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
* Send an update to VSCode
* @param values
*/
const sendUpdate = useCallback((values: string[]) => {
if (type === TagType.tags) {
Messenger.send(CommandToCode.updateTags, {
fieldName,
values,
renderAsString,
parents,
blockData
});
} else if (type === TagType.categories) {
Messenger.send(CommandToCode.updateCategories, {
fieldName,
values,
renderAsString,
parents,
blockData
});
} else if (type === TagType.keywords) {
Messenger.send(CommandToCode.updateKeywords, {
values,
parents
});
} else if (type === TagType.custom) {
Messenger.send(CommandToCode.updateCustomTaxonomy, {
id: taxonomyId,
name: fieldName,
options: values,
renderAsString,
parents,
blockData
} as CustomTaxonomyData);
}
}, [renderAsString]);
const sendUpdate = useCallback(
(values: string[]) => {
if (type === TagType.tags) {
Messenger.send(CommandToCode.updateTags, {
fieldName,
values,
renderAsString,
parents,
blockData
});
} else if (type === TagType.categories) {
Messenger.send(CommandToCode.updateCategories, {
fieldName,
values,
renderAsString,
parents,
blockData
});
} else if (type === TagType.keywords) {
Messenger.send(CommandToCode.updateKeywords, {
values,
parents
});
} else if (type === TagType.custom) {
Messenger.send(CommandToCode.updateCustomTaxonomy, {
id: taxonomyId,
name: fieldName,
options: values,
renderAsString,
parents,
blockData
} as CustomTaxonomyData);
}
},
[renderAsString]
);
/**
* Triggers the focus to the input when command is executed
@@ -182,19 +186,24 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
* @param option
* @param inputValue
*/
const filterList = useCallback((option: string, inputValue: string | null) => {
if (typeof option !== 'string') {
return false;
}
const filterList = useCallback(
(option: string, inputValue: string | null) => {
if (typeof option !== 'string') {
return false;
}
if (!(selected instanceof Array)) {
return true;
}
if (!(selected instanceof Array)) {
return true;
}
return (
option && !selected.includes(option) && option.toLowerCase().includes((inputValue || '').toLowerCase())
);
}, [selected]);
return (
option &&
!selected.includes(option) &&
option.toLowerCase().includes((inputValue || '').toLowerCase())
);
},
[selected]
);
/**
* Add the new item to the data
@@ -240,20 +249,29 @@ 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('');
}
}).catch(() => {
setLoading(false);
});
}, [selected]);
const suggestTaxonomy = useCallback(
(aiType: 'ai' | 'copilot', type: TagType) => {
setLoading(true);
const command =
aiType === 'ai' ? CommandToCode.aiSuggestTaxonomy : CommandToCode.copilotSuggestTaxonomy;
messageHandler
.request<string[]>(command, 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('');
}
})
.catch(() => {
setLoading(false);
});
},
[selected]
);
/**
* Check if the input is disabled
@@ -268,10 +286,13 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
const inputPlaceholder = useMemo((): string => {
if (checkIsDisabled()) {
return l10n.t(LocalizationKey.panelTagPickerInputPlaceholderDisabled, `${limit} ${label || type.toLowerCase()}`);
return l10n.t(
LocalizationKey.panelTagPickerInputPlaceholderDisabled,
`${limit} ${label || type.toLowerCase()}`
);
}
return l10n.t(LocalizationKey.panelTagPickerInputPlaceholderEmpty, (label || type.toLowerCase()));
return l10n.t(LocalizationKey.panelTagPickerInputPlaceholderEmpty, label || type.toLowerCase());
}, [label, type, checkIsDisabled]);
const showRequiredState = useMemo(() => {
@@ -279,25 +300,44 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
}, [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={l10n.t(LocalizationKey.panelTagPickerAiSuggest, (label?.toLowerCase() || type.toLowerCase()))}
type='button'
onClick={() => suggestTaxonomy(type)}
disabled={loading}>
<SparklesIcon />
</button>
<div className="flex gap-4">
{settings?.aiEnabled && (
<button
className="metadata_field__title__action"
title={l10n.t(
LocalizationKey.panelTagPickerAiSuggest,
label?.toLowerCase() || type.toLowerCase()
)}
type="button"
onClick={() => suggestTaxonomy('ai', type)}
disabled={loading}
>
<SparklesIcon />
</button>
)}
{settings?.copilotEnabled && (
<button
className="metadata_field__title__action"
title={l10n.t(
LocalizationKey.panelTagPickerCopilotSuggest,
label?.toLowerCase() || type.toLowerCase()
)}
type="button"
onClick={() => suggestTaxonomy('copilot', type)}
disabled={loading}
>
<CopilotIcon />
</button>
)}
</div>
);
}, [settings?.aiEnabled, label, type]);
}, [settings?.aiEnabled, settings?.copilotEnabled, label, type]);
const sortedSelectedTags = useMemo(() => {
const safeSelected = selected || [];
@@ -328,13 +368,6 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
return (
<div className={`article__tags metadata_field`}>
{
loading && (
<div className='metadata_field__loading'>
{l10n.t(LocalizationKey.panelTagPickerAiGenerating)}
</div>
)
}
<FieldTitle
label={
<>
@@ -342,7 +375,9 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
{limit !== undefined && limit > 0 ? (
<>
{` `}
<span style={{ fontWeight: 'lighter' }}>({l10n.t(LocalizationKey.panelTagPickerLimit, limit)})</span>
<span style={{ fontWeight: 'lighter' }}>
({l10n.t(LocalizationKey.panelTagPickerLimit, limit)})
</span>
</>
) : (
``
@@ -354,79 +389,86 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
required={required}
/>
<Downshift
ref={dsRef}
onChange={(selected) => onSelect(selected || '')}
itemToString={(item) => (item ? item : '')}
inputValue={inputValue}
onInputValueChange={(value) => setInputValue(value)}
>
{({
getInputProps,
getItemProps,
getMenuProps,
isOpen,
inputValue,
getRootProps,
openMenu,
closeMenu,
clearSelection,
highlightedIndex
}) => (
<>
<div
{...getRootProps(undefined, { suppressRefError: true })}
className={`article__tags__input ${freeform ? 'freeform' : ''} ${showRequiredState ? 'required' : ''
<div className="relative">
{loading && (
<div className="metadata_field__loading">
{l10n.t(LocalizationKey.panelTagPickerAiGenerating)}
</div>
)}
<Downshift
ref={dsRef}
onChange={(selected) => onSelect(selected || '')}
itemToString={(item) => (item ? item : '')}
inputValue={inputValue}
onInputValueChange={(value) => setInputValue(value)}
>
{({
getInputProps,
getItemProps,
getMenuProps,
isOpen,
inputValue,
getRootProps,
openMenu,
closeMenu,
clearSelection,
highlightedIndex
}) => (
<>
<div
{...getRootProps(undefined, { suppressRefError: true })}
className={`article__tags__input ${freeform ? 'freeform' : ''} ${
showRequiredState ? 'required' : ''
}`}
>
<input
{...getInputProps({
ref: inputRef,
onFocus: openMenu as any,
onClick: openMenu as any,
onKeyDown: (e) => onEnterData(e, closeMenu, highlightedIndex),
onBlur: () => {
closeMenu();
unsetFocus();
if (!inputValue) {
clearSelection();
}
},
disabled: checkIsDisabled()
})}
placeholder={inputPlaceholder}
/>
>
<input
{...getInputProps({
ref: inputRef,
onFocus: openMenu as any,
onClick: openMenu as any,
onKeyDown: (e) => onEnterData(e, closeMenu, highlightedIndex),
onBlur: () => {
closeMenu();
unsetFocus();
if (!inputValue) {
clearSelection();
}
},
disabled: checkIsDisabled()
})}
placeholder={inputPlaceholder}
/>
{freeform && (
<button
className={`article__tags__input__button`}
title={l10n.t(LocalizationKey.panelTagPickerUnkown)}
disabled={!inputValue || checkIsDisabled()}
onClick={() => insertUnkownTag(closeMenu)}
>
<AddIcon />
</button>
)}
</div>
{freeform && (
<button
className={`article__tags__input__button`}
title={l10n.t(LocalizationKey.panelTagPickerUnkown)}
disabled={!inputValue || checkIsDisabled()}
onClick={() => insertUnkownTag(closeMenu)}
>
<AddIcon />
</button>
)}
</div>
<ul
className={`field_dropdown article__tags__dropbox ${isOpen ? 'open' : 'closed'}`}
style={{
bottom: getDropdownStyle(isOpen)
}}
{...getMenuProps()}
>
{
options
<ul
className={`field_dropdown article__tags__dropbox ${isOpen ? 'open' : 'closed'}`}
style={{
bottom: getDropdownStyle(isOpen)
}}
{...getMenuProps()}
>
{options
.filter((option) => filterList(option, inputValue))
.map((item, index) => (
<li {...getItemProps({ key: item, index, item })}>{item}</li>
))
}
</ul>
</>
)}
</Downshift>
))}
</ul>
</>
)}
</Downshift>
</div>
<FieldMessage
name={(label || type).toLowerCase()}

View File

@@ -92,18 +92,23 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
}
}, [showRequiredState, isValid]);
const suggestDescription = (type: "ai" | "copilot") => {
const suggestDescription = (type: 'ai' | 'copilot') => {
setLoading(true);
messageHandler.request<string>(type === "copilot" ? CommandToCode.copilotDescription : CommandToCode.aiSuggestDescription).then((suggestion) => {
setLoading(false);
messageHandler
.request<string>(
type === 'copilot' ? CommandToCode.copilotSuggestDescription : CommandToCode.aiSuggestDescription
)
.then((suggestion) => {
setLoading(false);
if (suggestion) {
setText(suggestion);
onChange(suggestion);
}
}).catch(() => {
setLoading(false);
});
if (suggestion) {
setText(suggestion);
onChange(suggestion);
}
})
.catch(() => {
setLoading(false);
});
};
const actionElement = useMemo(() => {
@@ -112,38 +117,36 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
}
return (
<div className='flex gap-4'>
{
settings?.aiEnabled && (
<button
className='metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50'
title={l10n.t(LocalizationKey.panelFieldsTextFieldAiMessage, label?.toLowerCase())}
type='button'
onClick={() => suggestDescription("ai")}
disabled={loading}>
<SparklesIcon />
</button>
)
}
<div className="flex gap-4">
{settings?.aiEnabled && (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
title={l10n.t(LocalizationKey.panelFieldsTextFieldAiMessage, label?.toLowerCase())}
type="button"
onClick={() => suggestDescription('ai')}
disabled={loading}
>
<SparklesIcon />
</button>
)}
{
settings?.copilotEnabled && (
<button
className='metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50'
title={l10n.t(LocalizationKey.panelFieldsTextFieldCopilotMessage, label?.toLowerCase())}
type='button'
onClick={() => suggestDescription("copilot")}
disabled={loading}>
<CopilotIcon />
</button>
)
}
{settings?.copilotEnabled && (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
title={l10n.t(LocalizationKey.panelFieldsTextFieldCopilotMessage, label?.toLowerCase())}
type="button"
onClick={() => suggestDescription('copilot')}
disabled={loading}
>
<CopilotIcon />
</button>
)}
</div>
);
}, [settings?.aiEnabled, name]);
useEffect(() => {
if (text !== value && (lastUpdated === null || (Date.now() - DEBOUNCE_TIME) > lastUpdated)) {
if (text !== value && (lastUpdated === null || Date.now() - DEBOUNCE_TIME > lastUpdated)) {
setText(value || null);
}
setLastUpdated(null);
@@ -157,46 +160,49 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
return (
<div className={`metadata_field`}>
{
loading && (
<div className='metadata_field__loading'>
{l10n.t(LocalizationKey.panelFieldsTextFieldAiGenerate)}
</div>
)
}
<FieldTitle
label={label}
actionElement={actionElement}
icon={<PencilIcon />}
required={required} />
required={required}
/>
{wysiwyg ? (
<React.Suspense fallback={<div>{l10n.t(LocalizationKey.panelFieldsTextFieldLoading)}</div>}>
<WysiwygField text={text || ''} onChange={onTextChange} />
</React.Suspense>
) : singleLine ? (
<input
className={`metadata_field__input`}
value={text || ''}
onChange={(e) => onTextChange(e.currentTarget.value)}
placeholder={placeholder}
style={{
border
}}
/>
) : (
<textarea
className={`metadata_field__textarea`}
rows={rows || 2}
value={text || ''}
onChange={(e) => onTextChange(e.currentTarget.value)}
placeholder={placeholder}
style={{
border
}}
/>
)}
<div className='relative'>
{loading && (
<div className="metadata_field__loading">
{l10n.t(LocalizationKey.panelFieldsTextFieldAiGenerate)}
</div>
)}
{wysiwyg ? (
<React.Suspense
fallback={<div>{l10n.t(LocalizationKey.panelFieldsTextFieldLoading)}</div>}
>
<WysiwygField text={text || ''} onChange={onTextChange} />
</React.Suspense>
) : singleLine ? (
<input
className={`metadata_field__input`}
value={text || ''}
onChange={(e) => onTextChange(e.currentTarget.value)}
placeholder={placeholder}
style={{
border
}}
/>
) : (
<textarea
className={`metadata_field__textarea`}
rows={rows || 2}
value={text || ''}
onChange={(e) => onTextChange(e.currentTarget.value)}
placeholder={placeholder}
style={{
border
}}
/>
)}
</div>
{limit && limit > 0 && (text || '').length > limit && (
<div className={`metadata_field__limit`}>

View File

@@ -211,7 +211,7 @@ export const WrapperField: React.FunctionComponent<IWrapperFieldProps> = ({
singleLine={field.single}
limit={limit}
wysiwyg={field.wysiwyg}
rows={3}
rows={4}
onChange={onFieldChange}
value={(fieldValue as string) || null}
required={!!field.required}

View File

@@ -93,27 +93,29 @@ const Metadata: React.FunctionComponent<IMetadataProps> = ({
return (
<Collapsible
id={`tags`}
id={`metadata`}
title={`${l10n.t(LocalizationKey.panelMetadataTitle)}${contentType?.name ? ` (${contentType?.name})` : ""}`}
className={`inherit z-20`}>
<FeatureFlag features={features || DEFAULT_PANEL_FEATURE_FLAGS} flag={FEATURE_FLAG.panel.contentType}>
<ContentTypeValidator fields={contentType?.fields || []} metadata={metadata} />
</FeatureFlag>
{
metadata.fmError && metadata.fmErrorMessage ? (
<div className={`space-y-4`}>
<p className={`text-[var(--vscode-errorForeground)]`}>{metadata.fmError}</p>
<div className='metadata_fields space-y-6'>
{
metadata.fmError && metadata.fmErrorMessage ? (
<div className={`space-y-4`}>
<p className={`text-[var(--vscode-errorForeground)]`}>{metadata.fmError}</p>
<button
title={l10n.t(LocalizationKey.panelMetadataFocusProblems)}
onClick={focusProblems}
type={`button`}>
{l10n.t(LocalizationKey.panelMetadataFocusProblems)}
</button>
</div>
) : allFields
}
<button
title={l10n.t(LocalizationKey.panelMetadataFocusProblems)}
onClick={focusProblems}
type={`button`}>
{l10n.t(LocalizationKey.panelMetadataFocusProblems)}
</button>
</div>
) : allFields
}
</div>
</Collapsible>
);
};

View File

@@ -316,7 +316,6 @@ button {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
&.metadata_field__alert {
justify-content: flex-start;
@@ -371,7 +370,7 @@ button {
width: calc(100% + 2.5em);
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
top: 32px;
top: 0;
left: -1.25rem;
right: 0;
bottom: 0;

View File

@@ -1,8 +1,207 @@
import { extensions } from "vscode";
import {
CancellationTokenSource,
LanguageModelChatMessage,
LanguageModelChatResponse,
extensions,
lm,
version as VscodeVersion
} from 'vscode';
import { Logger, Settings, TaxonomyHelper } from '../helpers';
import { SETTING_SEO_DESCRIPTION_LENGTH, SETTING_SEO_TITLE_LENGTH } from '../constants';
import { TagType } from '../panelWebView/TagType';
import { TaxonomyType } from '../models';
export class Copilot {
private static personality =
'You are a CMS expert for Front Matter CMS and your task is to assist the user to help generate content for their article.';
/**
* Checks if the GitHub Copilot extension is installed.
* @returns A promise that resolves to a boolean indicating whether the extension is installed.
*/
public static async isInstalled(): Promise<boolean> {
if (!VscodeVersion.includes('insider')) {
// At the moment Copilot is only available in the insider version of VS Code
return false;
}
const copilotExt = extensions.getExtension(`GitHub.copilot`);
return !!copilotExt;
}
}
public static async suggestTitles(title: string): Promise<string[] | undefined> {
if (!title) {
return;
}
const chars = Settings.get<number>(SETTING_SEO_TITLE_LENGTH) || 60;
const messages = [
LanguageModelChatMessage.User(Copilot.personality),
LanguageModelChatMessage.User(
`The user wants you to create a SEO friendly title. You should give the user a couple of suggestions based on the provided title.
IMPORTANT: You are only allowed to respond with a text that should not exceed ${chars} characters in length.
Desired format: just a string, e.g. "My first blog post". Each suggestion is separated by a new line.`
),
LanguageModelChatMessage.User(`The title of the blog post is """${title}""".`)
];
const chatResponse = await this.getChatResponse(messages);
if (!chatResponse) {
return;
}
let titles = chatResponse.split('\n').map((title) => title.trim());
// Remove 1. or - from the beginning of the title
titles = titles.map((title) => title.replace(/^\d+\.\s+|-/, '').trim());
// Only take the titles wrapped in quotes
titles = titles.filter((title) => title.startsWith('"') && title.endsWith('"'));
// Remove the quotes from the beginning and end of the title
titles = titles.map((title) => title.slice(1, -1));
return titles;
}
/**
* Generates a SEO friendly abstract/description for an article based on the provided title and content.
*
* @param title - The title of the blog post.
* @param content - The content of the blog post.
* @returns A chat response containing the generated description.
*/
public static async suggestDescription(title: string, content?: string) {
if (!title) {
return;
}
const chars = Settings.get<number>(SETTING_SEO_DESCRIPTION_LENGTH) || 160;
const messages = [
LanguageModelChatMessage.User(Copilot.personality),
LanguageModelChatMessage.User(
`The user wants you to create a SEO friendly abstract/description. When the user provides a title and/or content, you should use this information to generate the description.
IMPORTANT: You are only allowed to respond with a text that should not exceed ${chars} characters in length.`
),
LanguageModelChatMessage.User(`The title of the blog post is """${title}""".`)
];
if (content) {
messages.push(
LanguageModelChatMessage.User(`The content of the blog post is: """${content}""".`)
);
}
const chatResponse = await this.getChatResponse(messages);
return chatResponse;
}
/**
* Suggests taxonomy tags based on the provided title, tag type, description, and content.
*
* @param title - The title of the blog post.
* @param tagType - The type of taxonomy tags to suggest (Tag or Category).
* @param description - The description of the blog post (optional).
* @param content - The content of the blog post (optional).
* @returns A promise that resolves to an array of suggested taxonomy tags, or undefined if no title is provided.
*/
public static async suggestTaxonomy(
title: string,
tagType: TagType,
description?: string,
content?: string
): Promise<string[] | undefined> {
if (!title) {
return;
}
const messages = [
LanguageModelChatMessage.User(Copilot.personality),
LanguageModelChatMessage.User(
`The user wants you to suggest some taxonomy tags. When the user provides a title, description, list of available taxonomy tags, and/or content, you should use this information to generate the tags.
IMPORTANT: You are only allowed to respond with a list of tags separated by commas. Example: tag1, tag2, tag3.`
),
LanguageModelChatMessage.User(`The title of the blog post is """${title}""".`)
];
if (description) {
messages.push(
LanguageModelChatMessage.User(`The description of the blog post is: """${description}""".`)
);
}
let options =
tagType === TagType.tags
? await TaxonomyHelper.get(TaxonomyType.Tag)
: await TaxonomyHelper.get(TaxonomyType.Category);
const optionsString = options?.join(',') || '';
if (optionsString) {
messages.push(
LanguageModelChatMessage.User(
`The available taxonomy tags are: ${optionsString}. Please select the tags that are relevant to the article. You are allowed to suggest a maximum of 5 tags and suggest new tags if necessary.`
)
);
}
if (content) {
messages.push(
LanguageModelChatMessage.User(`The content of the blog post is: """${content}""".`)
);
}
const chatResponse = await this.getChatResponse(messages);
if (!chatResponse) {
return;
}
// If the chat response contains a colon character, we take the text after the colon as the response.
if (chatResponse.includes(':')) {
return chatResponse
.split(':')[1]
.split(',')
.map((tag) => tag.trim());
}
// Otherwise, we split the response by commas.
return chatResponse.split(',').map((tag) => tag.trim());
}
/**
* Retrieves the chat response from the language model.
* @param messages - The chat messages to send to the language model.
* @returns The concatenated text fragments from the chat response.
*/
private static async getChatResponse(messages: LanguageModelChatMessage[]) {
let chatResponse: LanguageModelChatResponse | undefined;
try {
const model = await this.getModel();
chatResponse = await model.sendRequest(messages, {}, new CancellationTokenSource().token);
} catch (err) {
Logger.error(`Copilot:getChatResponse:: ${(err as Error).message}`);
return;
}
let allFragments = [];
for await (const fragment of chatResponse.text) {
allFragments.push(fragment);
}
return allFragments.join('');
}
/**
* Retrieves the chat model for the Copilot service.
* @returns A Promise that resolves to the chat model.
*/
private static async getModel() {
const [model] = await lm.selectChatModels({
vendor: 'copilot'
// family: 'gpt-4'
});
return model;
}
}