Skip to content

Commit

Permalink
Adds manage SharePoint Online resources GitHub Copilot Chat command. C…
Browse files Browse the repository at this point in the history
…loses #255, #331 (#341)

## 🎯 Aim

The aim of this PR is to:

- improve the general chat prompt to guide the user to the use the correct
chat command for the user intent
- add a new `/manage` command that will allow to retrieve data from
SharePoint

## 📷 Result

https://github.com/user-attachments/assets/7a1f1994-95c9-45ca-9015-94fb1a58c715

https://github.com/user-attachments/assets/24a722a7-3770-4252-be14-2a63f818c7d4

## ✅ What was done

- [x] improved general chat prompt
- [x] added manage chat command 
- [x] added script to parse CLI commands to create grounding data for
list and get commands for LLM
- [x] added separate LLM request to explain the CLI response 
- [x] improved transfer to `/code` chat command to include previous
history context

## 🔗 Related issue

Closes #255, #331
  • Loading branch information
Adam-it committed Dec 10, 2024
1 parent bd7895b commit 7bb31f0
Show file tree
Hide file tree
Showing 12 changed files with 2,202 additions and 24 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ Now you may use SPFx Toolkit as a chat participant in GitHub Copilot chat extens

![SPFx Toolkit chat in action](./assets/images/chat-in-action-setup.gif)

![SPFx Toolkit chat in action](./assets/images/chat-in-action-manage.gif)

@spfx is your dedicated AI Copilot that will help you with anything that is needed to develop your SharePoint Framework project. It has predefined commands that are tailored toward a specific activity for which you require guidance.

![SPFx Toolkit chat commands](./assets/images/chat-commands.png)
Expand All @@ -316,6 +318,7 @@ Currently, we support the following commands:
- `/setup` - that is dedicated to providing information on how to setup your local workspace for SharePoint Framework development
- `/new` - that may be used to get guidance on how to create a new solution or find and reuse an existing sample from the PnP SPFx sample gallery
- `/code` - that is fine-tuned to provide help in coding your SharePoint Framework project and provides additional boosters like validating the correctness of your SPFx project, scaffolding a CI/CD workflow, or renaming your project, and many more.
- `/manage` - this command is available only after sign in and allows you to ask questions about your SharePoint Online Tenant like checking the lists or list items or apps in app catalog and many more.

[Check out our docs for more details](https://github.com/pnp/vscode-viva/wiki/8.-SPFx-Toolkit-GitHub-Chat-Participant)

Expand Down
Binary file added assets/images/chat-in-action-menage.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,17 @@
{
"name": "new",
"isSticky": true,
"description": "Create a new SharePoint Framework project."
"description": "Create a new SharePoint Framework project"
},
{
"name": "manage",
"isSticky": true,
"description": "[beta] Manage your SharePoint Online tenant"
},
{
"name": "code",
"isSticky": true,
"description": "Let's write some SPFx code (I am still learning this, please consider this as beta)"
"description": "[beta] Let's write some SPFx code"
}
]
}
Expand Down
81 changes: 81 additions & 0 deletions scripts/cli-for-microsoft365-create-grounding-data.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
param ([string[]]$workspacePath)

$cliLocalProjectPath = "$workspacePath\cli-microsoft365"

if (-not (Test-Path -Path $cliLocalProjectPath -PathType Container)) {
return
}

[hashtable]$commandsData = @{}

$allSpoCommands = Get-ChildItem -Path "$workspacePath\cli-microsoft365\docs\docs\cmd\spo\*.mdx" -Recurse -Force -Exclude "_global*"

foreach ($command in $allSpoCommands) {
$commandDocs = ConvertFrom-Markdown -Path $command
$html = New-Object -Com 'HTMLFile'
$html.write([ref]$commandDocs.Html)

$title = $html.all.tags('h1')[0]
$commandTitle = $title.innerText

if (-not ($commandTitle -match "\b(list|get)$")) {
continue
}

$titleIndex = @($html.all).IndexOf($title)

$usage = $html.all.tags('h2') | Where-Object { $_.tagName -eq 'H2' } | Select-Object -First 1
$usageIndex = @($html.all).IndexOf($usage)

$commandDescription = @($html.all)[($titleIndex + 1)..($usageIndex - 1)]
$commandDescription = $commandDescription | ForEach-Object { $_.innerText }

$subTitles = $html.all.tags('h2') | Where-Object { $_.tagName -eq 'H2' } | Select-Object -First 5
$optionsStartIndex = @($html.all).IndexOf($subTitles[1])
$optionsEndIndex = @($html.all).IndexOf($subTitles[2])
$commandOptions = @($html.all)[($optionsStartIndex + 1)..($optionsEndIndex - 1)]
$commandOptions = $commandOptions | Where-Object { $_.nodeName -eq 'CODE' } | ForEach-Object { $_.innerText }
$commandOptions = $commandOptions | ForEach-Object { $_.Replace("`r`n", '') }

$examples = $subTitles[2].innerText
$examplesStartIndex = @($html.all).IndexOf($subTitles[2])
$examplesEndIndex = @($html.all).IndexOf($subTitles[3])
if (-not ($examples -match "Example")) {
$examples = $subTitles[3].innerText
$examplesStartIndex = @($html.all).IndexOf($subTitles[3])
$examplesEndIndex = @($html.all).IndexOf($subTitles[4])
}
$commandExamples = @($html.all)[($examplesStartIndex + 1)..($examplesEndIndex - 1)]
$commandExamples = $commandExamples | Where-Object { $_.nodeName -match 'CODE|P' } | ForEach-Object { $_.innerText }
$commandExamples = $commandExamples | Select-Object -Unique
$commandExamples = $commandExamples -split '\r?\n'
$commandExamplesObjects = @()
for ($i = 0; $i -lt $commandExamples.Length; $i += 2) {
$example = $commandExamples[$i]
$description = $commandExamples[$i + 1]
$commandExamplesObject = @{
Example = $example
Description = $description
}
$commandExamplesObjects += $commandExamplesObject
}

$commandsData["m365 $commandTitle"] = @{
Description = $commandDescription
Options = $commandOptions
Examples = $commandExamplesObjects
}
}

$dataArray = @()
foreach ($key in $commandsData.Keys) {
$orderedHashtable = [ordered]@{
Command = $key
Description = $commandsData[$key].Description
Options = $commandsData[$key].Options
Examples = $commandsData[$key].Examples
}
$dataArray += $orderedHashtable
}

$dataArray | ConvertTo-Json -Depth 3 | Out-File "$workspacePath\vscode-viva\src\chat\cli-for-microsoft365-spo-commands.ts"
95 changes: 80 additions & 15 deletions src/chat/PromptHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,45 @@
import * as vscode from 'vscode';
import { AdaptiveCardTypes, Commands, ComponentTypes, ExtensionTypes, msSampleGalleryLink, promptCodeContext, promptContext, promptGeneralContext, promptNewContext, promptSetupContext } from '../constants';
import { Logger } from '../services/dataType/Logger';
import { AdaptiveCardTypes, Commands, ComponentTypes, ExtensionTypes, msSampleGalleryLink, promptCodeContext, promptContext, promptExplainSharePointData, promptGeneralContext, promptMangeContext, promptNewContext, promptSetupContext } from '../constants';
import { ProjectInformation } from '../services/dataType/ProjectInformation';
import { CliActions } from '../services/actions/CliActions';
import { AuthProvider } from '../providers/AuthProvider';
import { EnvironmentInformation } from '../services/dataType/EnvironmentInformation';


export class PromptHandlers {
public static history: string[] = [];
public static previousCommand: string = '';
public static modelFamily = 'gpt-4o';

public static async handle(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<any> {
stream.progress(PromptHandlers.getRandomProgressMessage());
const chatCommand = (request.command && ['setup', 'new', 'code'].indexOf(request.command.toLowerCase()) > -1) ? request.command.toLowerCase() : '';
const chatCommand = (request.command && ['setup', 'new', 'code', 'manage'].indexOf(request.command.toLowerCase()) > -1) ? request.command.toLowerCase() : '';

if (chatCommand === 'manage') {
const authInstance = AuthProvider.getInstance();
const account = await authInstance.getAccount();
if (!account) {
stream.markdown('\n\n The `/manage` command is only available when you are signed in. Please sign in first.');
}
}

// TODO: in near future we may retrieve chat history like const previousMessages = context.history.filter((h) => h instanceof vscode.ChatResponseTurn );. currently it is only insiders
const messages: vscode.LanguageModelChatMessage[] = [];
messages.push(vscode.LanguageModelChatMessage.Assistant(promptContext));
messages.push(vscode.LanguageModelChatMessage.Assistant(PromptHandlers.getChatCommandPrompt(chatCommand)));

if (PromptHandlers.previousCommand !== chatCommand) {
if (PromptHandlers.previousCommand !== chatCommand && PromptHandlers.previousCommand !== '') {
PromptHandlers.history = [];
PromptHandlers.previousCommand = chatCommand;
} else {
PromptHandlers.history.forEach(message => messages.push(vscode.LanguageModelChatMessage.Assistant(message)));
}
PromptHandlers.previousCommand = chatCommand;

messages.push(vscode.LanguageModelChatMessage.User(request.prompt));
PromptHandlers.history.push(request.prompt);
const [model] = await vscode.lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' });
// TODO: in near future it will be possible to use user selected model like `await request.model.sendRequest(messages, {}, token);` now it is only available in insiders
const [model] = await vscode.lm.selectChatModels({ vendor: 'copilot', family: PromptHandlers.modelFamily });
try {
const chatResponse = await model.sendRequest(messages, {}, token);
let query = '';
Expand All @@ -34,32 +49,82 @@ export class PromptHandlers {
}
PromptHandlers.history.push(query);
PromptHandlers.getChatCommandButtons(chatCommand, query).forEach(button => stream.button(button));

if (chatCommand === 'manage') {
try {
const data = await PromptHandlers.tryToGetDataFromSharePoint(query);
if (data) {
stream.markdown('\n\nThis is what I found...\n\n');
const explanationResponse = await PromptHandlers.explainOverSharePointData(data, token);
let explanationQuery = '';
for await (const fragment of explanationResponse.text) {
explanationQuery += fragment;
stream.markdown(fragment);
}
PromptHandlers.history.push(explanationQuery);
}
} catch (err: any) {
Logger.getInstance();
Logger.error(err!.error ? err!.error.message.toString() : err.toString());
stream.markdown('\n\nI was not able to retrieve the data from SharePoint. Please check the logs in output window for more information.');
}
}

return { metadata: { command: chatCommand } };
} catch (err) {
if (err instanceof vscode.LanguageModelError) {
if (err.message.includes('off_topic')) {
stream.markdown('...I am sorry, I am not able to help with that. Please try again with a different question.');
stream.markdown('\n\n...I am sorry, I am not able to help with that. Please try again with a different question.');
}
} else {
stream.markdown('...It seems that something is not working as expected. Please try again later.');
stream.markdown('\n\n...It seems that something is not working as expected. Please try again later.');
}

return { metadata: { command: '' } };
}
}

private static async tryToGetDataFromSharePoint(chatResponse: string): Promise<string | undefined> {
const cliRegex = /```([^\n]*)\n(?=[\s\S]*?m365 spo.+)([\s\S]*?)\n?```/g;
const cliMatch = cliRegex.exec(chatResponse);

if (cliMatch && cliMatch[2]) {
const result = await CliActions.runCliCommand(cliMatch[2], 'md');
return result;
}

return;
}

private static async explainOverSharePointData(data: string, token: vscode.CancellationToken): Promise<vscode.LanguageModelChatResponse> {
const [model] = await vscode.lm.selectChatModels({ vendor: 'copilot', family: PromptHandlers.modelFamily });
const messages = [
vscode.LanguageModelChatMessage.User(promptExplainSharePointData),
vscode.LanguageModelChatMessage.User('Analyze and explain the following response:'),
vscode.LanguageModelChatMessage.User(data)
];
const chatResponse = await model.sendRequest(messages, {}, token);
return chatResponse;
}

private static getChatCommandPrompt(chatCommand: string): string {
let context: string = '';
switch (chatCommand) {
case 'setup':
context += promptSetupContext;
case 'new':
context += promptNewContext;
context += `\n Here is some more information regarding each component type ${ComponentTypes}`;
context += `\n Here is some more information regarding each extension type ${ExtensionTypes}`;
context += `\n Here is some more information regarding each ACE type ${AdaptiveCardTypes}`;
context += `\n Here is some more information regarding each component type ${JSON.stringify(ComponentTypes)}`;
context += `\n Here is some more information regarding each extension type ${JSON.stringify(ExtensionTypes)}`;
context += `\n Here is some more information regarding each ACE type ${JSON.stringify(AdaptiveCardTypes)}`;
case 'code':
context += promptCodeContext;
case 'manage':
// TODO: since we are already retrieving list of sites app catalog we could add it as additional context here
context += promptMangeContext;
if (EnvironmentInformation.tenantUrl) {
context += `Tenant SharePoint URL is: ${EnvironmentInformation.tenantUrl}`;
}
default:
context += promptGeneralContext;
}
Expand Down Expand Up @@ -93,14 +158,14 @@ export class PromptHandlers {
switch (chatCommand) {
case 'new':
const buttons = [];
const regex = /```([^\n]*)\n(?=[\s\S]*?yo @microsoft\/sharepoint.+)([\s\S]*?)\n?```/g;
const match = regex.exec(chatResponse);
if (match && match[2]) {
const yoRegex = /```([^\n]*)\n(?=[\s\S]*?yo @microsoft\/sharepoint.+)([\s\S]*?)\n?```/g;
const yoMatch = yoRegex.exec(chatResponse);
if (yoMatch && yoMatch[2]) {
buttons.push(
{
command: Commands.createProjectCopilot,
title: vscode.l10n.t('Create project'),
arguments: [match[2]],
arguments: [yoMatch[2]],
});
}
if (chatResponse.toLowerCase().includes(msSampleGalleryLink)) {
Expand All @@ -112,7 +177,7 @@ export class PromptHandlers {
return buttons;
case 'setup':
if (chatResponse.toLowerCase().includes('check dependencies')) {
return[{
return [{
command: Commands.checkDependencies,
title: vscode.l10n.t('Check if my local workspace is ready'),
}];
Expand Down
Loading

0 comments on commit 7bb31f0

Please sign in to comment.