diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 2a58552c..470274f3 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -431,6 +431,7 @@ "panel.fields.slugField.generate": "Generate slug", "panel.fields.textField.ai.message": "Use Front Matter AI to suggest {0}", + "panel.fields.textField.copilot.message": "Use Copilot to suggest {0}", "panel.fields.textField.ai.generate": "Generating suggestion...", "panel.fields.textField.loading": "Loading field", "panel.fields.textField.limit": "Field limit reached {0}", diff --git a/package-lock.json b/package-lock.json index c28b11c6..c8c81841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@types/react": "17.0.0", "@types/react-datepicker": "^4.8.0", "@types/react-dom": "17.0.0", - "@types/vscode": "^1.73.0", + "@types/vscode": "^1.90.0", "@typescript-eslint/eslint-plugin": "^5.50.0", "@typescript-eslint/parser": "^5.50.0", "@vscode-elements/elements": "^1.2.0", @@ -110,7 +110,7 @@ "yawn-yaml": "^1.5.0" }, "engines": { - "vscode": "^1.73.0" + "vscode": "^1.90.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2084,9 +2084,9 @@ "dev": true }, "node_modules/@types/vscode": { - "version": "1.86.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.86.0.tgz", - "integrity": "sha512-DnIXf2ftWv+9LWOB5OJeIeaLigLHF7fdXF6atfc7X5g2w/wVZBgk0amP7b+ub5xAuW1q7qP5YcFvOcit/DtyCQ==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.90.0.tgz", + "integrity": "sha512-oT+ZJL7qHS9Z8bs0+WKf/kQ27qWYR3trsXpq46YDjFqBsMLG4ygGGjPaJ2tyrH0wJzjOEmDyg9PDJBBhWg9pkQ==", "dev": true }, "node_modules/@types/vscode-webview": { diff --git a/package.json b/package.json index 8ab8b8f3..a1230994 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ }, "qna": "https://github.com/estruyf/vscode-front-matter/discussions", "engines": { - "vscode": "^1.73.0" + "vscode": "^1.90.0" }, "l10n": "./l10n", "categories": [ @@ -2779,7 +2779,7 @@ "@types/react": "17.0.0", "@types/react-datepicker": "^4.8.0", "@types/react-dom": "17.0.0", - "@types/vscode": "^1.73.0", + "@types/vscode": "^1.90.0", "@typescript-eslint/eslint-plugin": "^5.50.0", "@typescript-eslint/parser": "^5.50.0", "@vscode-elements/elements": "^1.2.0", diff --git a/src/helpers/PanelSettings.ts b/src/helpers/PanelSettings.ts index a3720726..195a2732 100644 --- a/src/helpers/PanelSettings.ts +++ b/src/helpers/PanelSettings.ts @@ -45,6 +45,7 @@ import { TaxonomyType } from '../models'; import { Folders } from '../commands'; +import { Copilot } from '../services/Copilot'; export class PanelSettings { public static async get(): Promise { @@ -53,6 +54,7 @@ export class PanelSettings { try { return { aiEnabled: Settings.get(SETTING_SPONSORS_AI_ENABLED) || false, + copilotEnabled: await Copilot.isInstalled(), git: await GitListener.getSettings(), seo: { title: (Settings.get(SETTING_SEO_TITLE_LENGTH) as number) || -1, diff --git a/src/listeners/panel/DataListener.ts b/src/listeners/panel/DataListener.ts index 7391dd71..78a2cafb 100644 --- a/src/listeners/panel/DataListener.ts +++ b/src/listeners/panel/DataListener.ts @@ -5,7 +5,16 @@ import { Folders } from '../../commands/Folders'; import { Command } from '../../panelWebView/Command'; import { CommandToCode } from '../../panelWebView/CommandToCode'; import { BaseListener } from './BaseListener'; -import { Uri, authentication, commands, window } from 'vscode'; +import { + CancellationTokenSource, + LanguageModelChatMessage, + LanguageModelChatResponse, + Uri, + authentication, + commands, + lm, + window +} from 'vscode'; import { ArticleHelper, Extension, @@ -25,6 +34,7 @@ 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'; @@ -104,6 +114,84 @@ export class DataListener extends BaseListener { case CommandToCode.aiSuggestDescription: this.aiSuggestTaxonomy(msg.command, msg.requestId); break; + case CommandToCode.copilotDescription: + this.copilotSuggestion(msg.command, msg.requestId); + break; + } + } + + private static async copilotSuggestion(command: string, requestId?: string) { + if (!command || !requestId) { + 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 [model] = await lm.selectChatModels({ + vendor: 'copilot', + // family: 'gpt-4' + }); + + // TODO: Create settings for: copilot.description.message, copilot.family, + + const chars = Settings.get(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}`); + panel.getWebview()?.postMessage({ + command, + requestId, + error: l10n.t(LocalizationKey.listenersPanelDataListenerAiSuggestTaxonomyNoDataError) + } as MessageHandlerData); + return; + } + + let allFragments = []; + for await (const fragment of chatResponse.text) { + allFragments.push(fragment); + } + + if (allFragments.length > 0) { + const description = allFragments.join(''); + panel.getWebview()?.postMessage({ + command, + requestId, + payload: description.trim() + } as MessageHandlerData); } } diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index 726254b0..b4c15586 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -1396,6 +1396,10 @@ export enum LocalizationKey { * Use Front Matter AI to suggest {0} */ panelFieldsTextFieldAiMessage = 'panel.fields.textField.ai.message', + /** + * Use Copilot to suggest {0} + */ + panelFieldsTextFieldCopilotMessage = 'panel.fields.textField.copilot.message', /** * Generating suggestion... */ diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index b378fc7a..5a64c84a 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -29,6 +29,7 @@ export interface PanelSettings { fieldGroups: FieldGroup[] | undefined; commaSeparatedFields: string[]; aiEnabled: boolean; + copilotEnabled: boolean; contentFolders: ContentFolder[]; websiteUrl: string; disabledActions: PanelAction[]; diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index 5eef2f91..907e5620 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -42,6 +42,7 @@ export enum CommandToCode { stopServer = 'stop-server', aiSuggestTaxonomy = 'ai-suggest-taxonomy', aiSuggestDescription = 'ai-suggest-description', + copilotDescription = 'copilot-suggest-description', searchByType = 'search-by-type', processMediaData = 'process-media-data', isServerStarted = 'is-server-started' diff --git a/src/panelWebView/components/Fields/FieldTitle.tsx b/src/panelWebView/components/Fields/FieldTitle.tsx index f86a6810..3f83cb9c 100644 --- a/src/panelWebView/components/Fields/FieldTitle.tsx +++ b/src/panelWebView/components/Fields/FieldTitle.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { useMemo } from 'react'; import { RequiredAsterix } from './RequiredAsterix'; -import { VSCodeLabel } from '../VSCode'; export interface IFieldTitleProps { label: string | JSX.Element; @@ -23,16 +22,14 @@ export const FieldTitle: React.FunctionComponent = ({ }, [icon]); return ( - -
-
- {Icon} - {label} - -
+
+ - {actionElement} -
- + {actionElement} +
); }; diff --git a/src/panelWebView/components/Fields/TextField.tsx b/src/panelWebView/components/Fields/TextField.tsx index 097228a4..5ba9a3e1 100644 --- a/src/panelWebView/components/Fields/TextField.tsx +++ b/src/panelWebView/components/Fields/TextField.tsx @@ -11,6 +11,7 @@ import { CommandToCode } from '../../CommandToCode'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../../../localization'; import { useDebounce } from '../../../hooks/useDebounce'; +import { CopilotIcon } from '../Icons'; const DEBOUNCE_TIME = 300; @@ -91,9 +92,9 @@ export const TextField: React.FunctionComponent = ({ } }, [showRequiredState, isValid]); - const suggestDescription = () => { + const suggestDescription = (type: "ai" | "copilot") => { setLoading(true); - messageHandler.request(CommandToCode.aiSuggestDescription).then((suggestion) => { + messageHandler.request(type === "copilot" ? CommandToCode.copilotDescription : CommandToCode.aiSuggestDescription).then((suggestion) => { setLoading(false); if (suggestion) { @@ -106,19 +107,38 @@ export const TextField: React.FunctionComponent = ({ }; const actionElement = useMemo(() => { - if (!settings?.aiEnabled || settings.seo.descriptionField !== name) { + if (settings.seo.descriptionField !== name) { return; } return ( - +
+ { + settings?.aiEnabled && ( + + ) + } + + { + settings?.copilotEnabled && ( + + ) + } +
); }, [settings?.aiEnabled, name]); @@ -145,7 +165,11 @@ export const TextField: React.FunctionComponent = ({ ) } - } required={required} /> + } + required={required} /> {wysiwyg ? ( {l10n.t(LocalizationKey.panelFieldsTextFieldLoading)}}> diff --git a/src/panelWebView/components/Icons/CopilotIcon.tsx b/src/panelWebView/components/Icons/CopilotIcon.tsx new file mode 100644 index 00000000..5c7d1174 --- /dev/null +++ b/src/panelWebView/components/Icons/CopilotIcon.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; + +export interface ICopilotIconProps { } + +export const CopilotIcon: React.FunctionComponent = (props: React.PropsWithChildren) => { + return ( + + + + ); +}; \ No newline at end of file diff --git a/src/panelWebView/components/Icons/index.ts b/src/panelWebView/components/Icons/index.ts index f02ef818..cd7d5268 100644 --- a/src/panelWebView/components/Icons/index.ts +++ b/src/panelWebView/components/Icons/index.ts @@ -3,6 +3,7 @@ export * from './ArchiveIcon'; export * from './BranchIcon'; export * from './BugIcon'; export * from './CenterIcon'; +export * from './CopilotIcon'; export * from './FileIcon'; export * from './FolderOpenedIcon'; export * from './FrontMatterIcon'; diff --git a/src/panelWebView/styles.css b/src/panelWebView/styles.css index 8232ab3d..d9a76601 100644 --- a/src/panelWebView/styles.css +++ b/src/panelWebView/styles.css @@ -326,31 +326,30 @@ button { display: flex; align-items: center; } +} - button { - all: unset; +.metadata_field__title__action { + all: unset; + display: inline-flex; + justify-content: center; + background: none; + height: 16px; + width: 16px; + + &:hover { + color: var(--vscode-button-hoverBackground); + fill: var(--vscode-button-hoverBackground); + background: none; + cursor: pointer; } - .metadata_field__title__action { - display: inline-flex; - justify-content: center; - height: 16px; - width: 16px; + &:disabled { + opacity: 0.5; + color: var(--vscode-disabledForeground); + } - &: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; - } + svg { + margin-right: 0; } } @@ -364,15 +363,15 @@ button { } .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; + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + top: 32px; left: -1.25rem; right: 0; bottom: 0; diff --git a/src/services/Copilot.ts b/src/services/Copilot.ts new file mode 100644 index 00000000..0a5d537c --- /dev/null +++ b/src/services/Copilot.ts @@ -0,0 +1,8 @@ +import { extensions } from "vscode"; + +export class Copilot { + public static async isInstalled(): Promise { + const copilotExt = extensions.getExtension(`GitHub.copilot`); + return !!copilotExt; + } +} \ No newline at end of file