chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1,22 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,54 @@
# React App for SK Process with Cloud Events
## Getting Started
Follow the steps below to set up, run, and debug the React app for SK Process with Cloud Events.
### Prerequisites
- Node.js (LTS version recommended)
- Yarn (package manager)
### Installation
1. Navigate to the project directory:
```bash
cd <repo>/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client
```
2. Install the dependencies:
```bash
yarn install
```
Alternatively, you can use the existing Visual Studio Code task:
1. Open the Command Palette in Visual Studio Code (`Ctrl+Shift+P` or `Cmd+Shift+P` on macOS).
2. Search for and select `Tasks: Run Task`.
3. Choose the `yarn: install` task from the list to install the dependencies.
### Running the Application
1. Ensure that the backend server is running.
2. Start the development server:
```bash
yarn: run dev
```
Alternatively, you can use the existing Visual Studio Code task `yarn: run dev`.
3. Open your browser and navigate to `http://localhost:5173` to view the app.
### Usage
1. Select cloud technology to be used.
2. Select SK Process to be used.
3. Interact with the UI to send events/messages to the backend. The UI will display any incoming events/messages from the backend.
4. Use the provided buttons/inputs to trigger specific actions or events as needed.
### Debugging
1. Run the application.
2. Start the app in debug mode:
- If using Visual Studio Code, go to the "Run and Debug" panel and select `Launch Edge against localhost`.
3. Set breakpoints in your code to inspect and debug as needed.
For more details, refer to the official React documentation: [React Docs](https://reactjs.org/docs/getting-started.html).
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SK Processes + Cloud Events</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
{
"name": "processwithcloudevents-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fluentui-contrib/react-chat": "^0.1.10",
"@fluentui/react-components": "^9.61.6",
"@protobuf-ts/grpcweb-transport": "^2.9.6",
"@protobuf-ts/runtime": "^2.9.6",
"@protobuf-ts/runtime-rpc": "^2.9.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@protobuf-ts/plugin": "^2.9.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.4.1"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
@@ -0,0 +1,306 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
import {
Button,
Divider,
Dropdown,
makeStyles,
MessageBar,
MessageBarActions,
MessageBarBody,
MessageBarTitle,
Option,
Title1,
} from "@fluentui/react-components";
import { useState } from "react";
import "./App.css";
import {
AppPages,
AppPagesDetails,
CloudTechnologiesDetails,
CloudTechnology,
} from "./common/AppConstants";
import GenerateDocsChat, {
DocumentReview,
NewDocument,
} from "./components/GenerateDocumentsChat";
import { ExitIcon } from "./components/Icons";
import { GrpcDocumentationGenerationClient } from "./services/grpc/gen/documentGeneration.client";
interface AppProps {
grpcDocClient?: GrpcDocumentationGenerationClient;
}
const useStyles = makeStyles({
root: {
alignItems: "flex-start",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
rowGap: "20px",
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
},
innerContainer: {
margin: "20px",
width: "96%",
height: "100%",
},
divider: {
marginTop: "20px",
marginBottom: "20px",
},
dropdownContainer: {
display: "grid",
gridTemplateRows: "repeat(2fr)",
justifyItems: "start",
gap: "2px",
maxWidth: "400px",
},
settingsContainer: {
display: "flex",
columnGap: "20px",
},
});
const App: React.FC<AppProps> = ({ grpcDocClient }) => {
const styles = useStyles();
const [selectedCloudTech, setSelectedCloudTech] = useState<CloudTechnology>(
CloudTechnology.GRPC
);
const [selectedAppPage, setSelectedAppPage] = useState<AppPages>(
AppPages.DocumentGeneration
);
const [generatedDocuments, setGeneratedDocuments] = useState<NewDocument[]>(
[]
);
const [publishedDocuments, setPublishedDocuments] = useState<NewDocument[]>(
[]
);
const [hasGrpcError, setHasGrpcError] = useState(false);
const onCreateDocumentRequest = (
document: NewDocument
): Promise<string> => {
if (selectedCloudTech == CloudTechnology.GRPC) {
if (grpcDocClient) {
return grpcDocClient
.userRequestFeatureDocumentation({
processId: document.processId,
content: document.title ?? "",
title: document.title ?? "",
userDescription: "",
})
.then((result) => {
setHasGrpcError(false);
return result.response.processId;
})
.catch((error) => {
console.error(
"[GRPC] Error requesting document generation",
error
);
setHasGrpcError(true);
return "";
});
}
}
return new Promise((resolve) => resolve(""));
};
const onUserReviewedDocument = (
userReview: DocumentReview
): Promise<boolean> => {
if (selectedCloudTech == CloudTechnology.GRPC) {
if (grpcDocClient) {
return grpcDocClient
.userReviewedDocumentation({
processData: {
processId: userReview.processId,
},
documentationApproved: userReview.accepted,
reason: userReview.suggestions,
})
.then(() => {
console.log("[GRPC] User document review submitted");
setHasGrpcError(false);
return true;
})
.catch((error) => {
console.error(
"[GRPC] Error submitting user document review",
error
);
setHasGrpcError(true);
return false;
});
}
}
return new Promise((resolve) => resolve(false));
};
const subscribeReceiveDocumentForReview = async (processId: string) => {
if (selectedCloudTech == CloudTechnology.GRPC) {
if (grpcDocClient) {
// grpc stream for receiving document for review
const reviewDocumentStream =
grpcDocClient.requestUserReviewDocumentation({
processId: processId,
});
for await (const message of reviewDocumentStream.responses) {
setGeneratedDocuments((prevDocs) => [
...prevDocs,
{
processId: message.processData!.processId!,
content: message.content,
title: message.title,
},
]);
console.log("[GRPC] Review document received: ", message);
}
}
}
};
const subscribeToReceivePublishedDocument = async (processId: string) => {
if (selectedCloudTech == CloudTechnology.GRPC) {
if (grpcDocClient) {
// grpc stream for receiving published document
const publishedDocumentStream =
grpcDocClient.receivePublishedDocumentation({
processId: processId,
});
for await (const message of publishedDocumentStream.responses) {
setPublishedDocuments((prevDocs) => [
...prevDocs,
{
processId: message.processData!.processId!,
content: message.content,
title: message.title,
},
]);
console.log(
"[GRPC] Published document received: ",
message
);
}
}
}
};
const subscribeToSpecificProcessId = async (processId: string) => {
subscribeReceiveDocumentForReview(processId);
subscribeToReceivePublishedDocument(processId);
return Promise.all([
subscribeReceiveDocumentForReview(processId),
subscribeToReceivePublishedDocument(processId),
]).then(() => {
return;
});
};
return (
<div className={styles.root}>
<div className={styles.innerContainer}>
<Title1>SK Processes with Cloud events</Title1>
<Divider className={styles.divider} />
<div className={styles.settingsContainer}>
<div className={styles.dropdownContainer}>
<label>1. Select cloud technology: </label>
<Dropdown
onOptionSelect={(
_e: any,
data: { optionValue?: string }
) =>
setSelectedCloudTech(
data.optionValue as CloudTechnology
)
}
defaultValue={
CloudTechnologiesDetails.get(selectedCloudTech)
?.name
}
defaultSelectedOptions={[selectedCloudTech]}
>
{[...CloudTechnologiesDetails.entries()].map(
([tech, detail]) => (
<Option key={tech} value={tech}>
{detail.name}
</Option>
)
)}
</Dropdown>
</div>
<div className={styles.dropdownContainer}>
<label>2. Select app to use</label>
<Dropdown
onOptionSelect={(
_e: any,
data: { optionValue?: string }
) =>
setSelectedAppPage(data.optionValue as AppPages)
}
defaultValue={
AppPagesDetails.get(selectedAppPage)?.name
}
defaultSelectedOptions={[selectedAppPage]}
>
{[...AppPagesDetails.entries()].map(
([app, detail]) => (
<Option key={app} value={app}>
{detail.name}
</Option>
)
)}
</Dropdown>
</div>
</div>
<Divider className={styles.divider} />
{hasGrpcError && (
<MessageBar intent="warning">
<MessageBarActions
containerAction={
<Button
aria-label="dismiss"
appearance="transparent"
icon={<ExitIcon />}
onClick={() => setHasGrpcError(false)}
/>
}
/>
<MessageBarBody>
<MessageBarTitle>gRPC Client Error</MessageBarTitle>
Cannot connect to gRPC Document Generator server,
make sure server is running and try again.
</MessageBarBody>
</MessageBar>
)}
{selectedAppPage == AppPages.DocumentGeneration && (
<GenerateDocsChat
cloudTechnologyName={
CloudTechnologiesDetails.get(selectedCloudTech)!
.name
}
onCreateNewDocument={onCreateDocumentRequest}
onUserReviewedDocument={onUserReviewedDocument}
subscribeToSpecificProcessId={
subscribeToSpecificProcessId
}
generatedDocuments={generatedDocuments}
publishedDocuments={publishedDocuments}
/>
)}
</div>
</div>
);
};
export default App;
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
// When more process samples are added, add them to this enum and the AppPagesDetails map below.
// Additionally update the AppPagesDetails map to include the new process sample and its details.
export enum AppPages {
DocumentGeneration = "DocumentGeneration",
}
interface EnumDetails {
name: string;
description: string;
}
export const AppPagesDetails = new Map<AppPages, EnumDetails>([
[
AppPages.DocumentGeneration,
{
name: "Document Generation",
description:
"Demo used to show case document generation using different cloud technologies with SK Processes",
},
],
]);
// When more cloud technologies are added, add them to this enum and the CloudTechnologiesDetails map below.
// Additionally update the CloudTechnologiesDetails map to include the new technology and its details.
export enum CloudTechnology {
GRPC = "GRPC",
}
export const CloudTechnologiesDetails = new Map<CloudTechnology, EnumDetails>(
[
[CloudTechnology.GRPC, {name: "gRPC", description: "gRPC Protocol"}]
]
);
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
export enum ChatUser {
USER = "USER",
ASSISTANT = "ASSISTANT",
}
export interface ChatMessageContent {
sender: ChatUser;
content?: unknown;
action?: string;
timestamp?: string;
}
@@ -0,0 +1,356 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
import {
Button,
Card,
CardHeader,
Input,
Label,
makeStyles,
Popover,
PopoverSurface,
PopoverTrigger,
Spinner,
Title2,
useId,
} from "@fluentui/react-components";
import { useEffect, useState } from "react";
import Markdown from "react-markdown";
import { v4 as uuidv4 } from "uuid";
import { ChatMessageContent, ChatUser } from "../common/ChatConstants";
import { CheckIcon, RejectIcon } from "./Icons";
import SimpleChat from "./SimpleChat";
export interface NewDocument {
title?: string;
processId: string;
content?: string;
}
export interface DocumentReview {
accepted: boolean;
processId: string;
suggestions: string;
}
interface GenerateDocsChatProps {
cloudTechnologyName: string;
generatedDocuments: NewDocument[];
publishedDocuments: NewDocument[];
onCreateNewDocument: (document: NewDocument) => Promise<string>;
onUserReviewedDocument: (userReview: DocumentReview) => Promise<boolean>;
subscribeToSpecificProcessId: (processId: string) => Promise<void>;
}
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
rowGap: "8px",
width: "90%",
},
processIdContainer: {
display: "flex",
flexDirection: "column",
rowGap: "8px",
alignItems: "flex-end",
},
buttonsFamily: {
display: "flex",
columnGap: "40px",
},
suggestionsContainer: {
display: "flex",
flexDirection: "column",
rowGap: "8px",
},
newDocHeaderHeader: {
marginTop: "0",
},
headerContainer: {
display: "flex",
justifyContent: "space-between",
},
});
const GenerateDocsChat: React.FC<GenerateDocsChatProps> = ({
cloudTechnologyName,
generatedDocuments,
publishedDocuments,
onCreateNewDocument,
onUserReviewedDocument,
subscribeToSpecificProcessId,
}) => {
const styles = useStyles();
const newDocNameId = useId("input");
const docRejectedId = useId("input");
const [messages, setMessages] = useState<ChatMessageContent[]>([]);
const [processId, setProcessId] = useState<string>();
const [newDocContent, setNewDocContent] = useState<string>();
const [rejectSuggestions, setRejectSuggestions] = useState<string>();
const [creatingNewDocument, setCreatingNewDocument] =
useState<boolean>(false);
const [allowUserReview, setAllowUserReview] = useState<boolean>(false);
useEffect(() => {
if (processId) {
subscribeToSpecificProcessId(processId);
}
}, [processId]);
const formatNewDocumentString = (doc: NewDocument, header: string) => {
const content = `### Title: ${doc.title}\n### Content:\n${doc.content}`;
return (
<Card>
<CardHeader header={<Title2>{header}</Title2>} />
<Markdown>{content}</Markdown>
</Card>
);
};
useEffect(() => {
if (generatedDocuments.length > 0) {
const lastDoc = generatedDocuments[generatedDocuments.length - 1];
setMessages((prevMessages) => [
...prevMessages,
{
sender: ChatUser.ASSISTANT,
content: formatNewDocumentString(
lastDoc,
"Document for review"
),
timestamp: new Date().toLocaleString(),
},
]);
setAllowUserReview(true);
}
}, [generatedDocuments]);
useEffect(() => {
if (publishedDocuments.length > 0) {
const lastDoc = publishedDocuments[publishedDocuments.length - 1];
setMessages((prevMessages) => [
...prevMessages,
{
sender: ChatUser.ASSISTANT,
content: formatNewDocumentString(
lastDoc,
"Published document"
),
timestamp: new Date().toLocaleString(),
},
]);
setAllowUserReview(false);
}
}, [publishedDocuments]);
const onCreateNewDocumentClicked = () => {
if (newDocContent === "") {
alert("Document title cannot be empty");
return;
}
// Need to know processId to be able to subscribe to incoming events from this process once it is running
// processId is used as identifier to start/resume process
const newProcessId = uuidv4();
setProcessId(newProcessId);
setAllowUserReview(false);
onCreateNewDocument({
processId: newProcessId,
title: newDocContent ?? "",
})
.then((result) => {
setMessages((prevMessages) => [
...prevMessages,
{
sender: ChatUser.ASSISTANT,
action: `Document generated - ${result}`,
},
]);
})
.finally(() => {
setCreatingNewDocument(false);
});
setMessages((prevMessages) => [
...prevMessages,
{
sender: ChatUser.USER,
content: `User created new document request - ${newDocContent}`,
timestamp: new Date().toLocaleString(),
},
]);
setCreatingNewDocument(true);
};
const onClearChat = () => {
setMessages([]);
setProcessId("");
setRejectSuggestions("");
setAllowUserReview(false);
};
const onUserRejectedDocument = () => {
if (!processId) {
alert("Process id cannot be empty, create document first");
return;
}
if (!rejectSuggestions) {
alert(
"Must provide non empty suggestions on rejection of a document"
);
return;
}
onUserReviewedDocument({
accepted: false,
suggestions: rejectSuggestions ?? "",
processId: processId!,
}).then(() => {
setMessages((prevMessages) => [
...prevMessages,
{
sender: ChatUser.ASSISTANT,
action: "Document generated with suggestions",
},
]);
});
setMessages((prevMessages) => [
...prevMessages,
{
sender: ChatUser.USER,
content: `User rejected document providing suggestions: ${rejectSuggestions}`,
timestamp: new Date().toLocaleString(),
},
{ sender: ChatUser.ASSISTANT, action: "Document rejected" },
]);
setRejectSuggestions("");
};
const onUserApprovedDocument = () => {
if (!processId) {
alert("Process id cannot be empty, create document first");
return;
}
onUserReviewedDocument({
accepted: true,
suggestions: "",
processId: processId!,
}).then(() => {
setMessages((prevMessages) => [
...prevMessages,
{ sender: ChatUser.ASSISTANT, action: "Document Approved" },
]);
});
setMessages((prevMessages) => [
...prevMessages,
{
sender: ChatUser.USER,
content: "User send approval of document",
timestamp: new Date().toLocaleString(),
},
]);
setRejectSuggestions("");
};
return (
<div className={styles.root}>
<div className={styles.headerContainer}>
<Title2>Document Generation with {cloudTechnologyName}</Title2>
<div className={styles.processIdContainer}>
<Label>ProcessId : </Label>
<Label>{processId ?? "-"}</Label>
</div>
</div>
<SimpleChat messages={messages} />
<div className={styles.buttonsFamily}>
<Popover withArrow>
<PopoverTrigger>
<Button
appearance="primary"
icon={
creatingNewDocument ? (
<Spinner size="tiny" />
) : undefined
}
>
Create new document
</Button>
</PopoverTrigger>
<PopoverSurface>
<div>
<h3 className={styles.newDocHeaderHeader}>
New Document
</h3>
<Label htmlFor={newDocNameId}>Title/Name: </Label>
<Input
required
id={newDocNameId}
onChange={(_e, d) => setNewDocContent(d.value)}
/>
<Button onClick={onCreateNewDocumentClicked}>
Create
</Button>
</div>
</PopoverSurface>
</Popover>
<div>
<Button
icon={<CheckIcon />}
onClick={onUserApprovedDocument}
disabled={!allowUserReview}
>
Accept Document
</Button>
<Popover withArrow>
<PopoverTrigger>
<Button
icon={<RejectIcon />}
disabled={!allowUserReview}
>
Reject Document
</Button>
</PopoverTrigger>
<PopoverSurface>
<div className={styles.suggestionsContainer}>
<h3 className={styles.newDocHeaderHeader}>
Document Rejected - add suggestions
</h3>
<div>
<Label htmlFor={docRejectedId}>
Suggestions:{" "}
</Label>
<Input
onChange={(_e, d) =>
setRejectSuggestions(d.value)
}
required
id={docRejectedId}
/>
</div>
<Button onClick={onUserRejectedDocument}>
Send suggestions
</Button>
</div>
</PopoverSurface>
</Popover>
</div>
<Button appearance="subtle" onClick={onClearChat}>
Clear chat
</Button>
</div>
</div>
);
};
export default GenerateDocsChat;
@@ -0,0 +1,9 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
import { CheckmarkRegular, DismissRegular } from "@fluentui/react-icons";
export const CheckIcon = CheckmarkRegular;
export const RejectIcon = DismissRegular;
export const ExitIcon = DismissRegular;
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
import { Chat, ChatMessage, ChatMyMessage } from "@fluentui-contrib/react-chat";
import { Divider, makeStyles } from "@fluentui/react-components";
import { ChatMessageContent, ChatUser } from "../common/ChatConstants";
const useStyles = makeStyles({
chatContainer: {
minHeight: "50vh",
overflowY: "auto",
boxSizing: "border-box",
border: "1px solid #ccc",
maxHeight: "50vh",
width: "100%",
},
});
interface SimpleChatProps {
messages: ChatMessageContent[];
}
const SimpleChat: React.FC<SimpleChatProps> = ({ messages }) => {
const styles = useStyles();
const renderMessage = (message: ChatMessageContent) => {
if (message.action) {
return <Divider key={`action-${message.action}-${message.sender}`}>{message.action}</Divider>;
}
switch (message.sender) {
case ChatUser.ASSISTANT:
return <ChatMessage author={"Assistant"} timestamp={message.timestamp} key={`assistant-${message.timestamp}`}>{message.content}</ChatMessage>;
case ChatUser.USER:
return <ChatMyMessage author={"User"} timestamp={message.timestamp} key={`user-${message.timestamp}`}>{message.content}</ChatMyMessage>;
default:
return <></>;
}
};
return (
<Chat className={styles.chatContainer}>
{messages.map((m) => renderMessage(m))}
</Chat>
);
};
export default SimpleChat;
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
import { createRoot } from "react-dom/client";
import { grpcDocService } from "./services/grpc/DocumentGenerationGrpcClient.ts";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<FluentProvider theme={webLightTheme}>
<App grpcDocClient={grpcDocService} />
</FluentProvider>
);
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
import { GrpcDocumentationGenerationClient } from "./gen/documentGeneration.client";
const createGrpcDocGenerationClient = () => {
try {
const transport = new GrpcWebFetchTransport({
baseUrl: "http://localhost:58640",
format: "text",
});
return new GrpcDocumentationGenerationClient(transport);
} catch (error) {
console.error("Could not create connection with gRPC server", error);
return undefined;
}
};
export const grpcDocService = createGrpcDocGenerationClient();
@@ -0,0 +1,96 @@
// @generated by protobuf-ts 2.9.6 with parameter generate_dependencies
// @generated from protobuf file "documentGeneration.proto" (syntax proto3)
// tslint:disable
import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
import type { ServiceInfo } from "@protobuf-ts/runtime-rpc";
import { GrpcDocumentationGeneration } from "./documentGeneration";
import type { DocumentationApprovalRequest } from "./documentGeneration";
import type { ServerStreamingCall } from "@protobuf-ts/runtime-rpc";
import type { Empty } from "./documentGeneration";
import type { DocumentationContentRequest } from "./documentGeneration";
import { stackIntercept } from "@protobuf-ts/runtime-rpc";
import type { ProcessData } from "./documentGeneration";
import type { FeatureDocumentationRequest } from "./documentGeneration";
import type { UnaryCall } from "@protobuf-ts/runtime-rpc";
import type { RpcOptions } from "@protobuf-ts/runtime-rpc";
/**
* @generated from protobuf service GrpcDocumentationGeneration
*/
export interface IGrpcDocumentationGenerationClient {
/**
* @generated from protobuf rpc: UserRequestFeatureDocumentation(FeatureDocumentationRequest) returns (ProcessData);
*/
userRequestFeatureDocumentation(input: FeatureDocumentationRequest, options?: RpcOptions): UnaryCall<FeatureDocumentationRequest, ProcessData>;
/**
* @generated from protobuf rpc: RequestUserReviewDocumentationFromProcess(DocumentationContentRequest) returns (Empty);
*/
requestUserReviewDocumentationFromProcess(input: DocumentationContentRequest, options?: RpcOptions): UnaryCall<DocumentationContentRequest, Empty>;
/**
* @generated from protobuf rpc: RequestUserReviewDocumentation(ProcessData) returns (stream DocumentationContentRequest);
*/
requestUserReviewDocumentation(input: ProcessData, options?: RpcOptions): ServerStreamingCall<ProcessData, DocumentationContentRequest>;
/**
* @generated from protobuf rpc: UserReviewedDocumentation(DocumentationApprovalRequest) returns (Empty);
*/
userReviewedDocumentation(input: DocumentationApprovalRequest, options?: RpcOptions): UnaryCall<DocumentationApprovalRequest, Empty>;
/**
* @generated from protobuf rpc: PublishDocumentation(DocumentationContentRequest) returns (Empty);
*/
publishDocumentation(input: DocumentationContentRequest, options?: RpcOptions): UnaryCall<DocumentationContentRequest, Empty>;
/**
* @generated from protobuf rpc: ReceivePublishedDocumentation(ProcessData) returns (stream DocumentationContentRequest);
*/
receivePublishedDocumentation(input: ProcessData, options?: RpcOptions): ServerStreamingCall<ProcessData, DocumentationContentRequest>;
}
/**
* @generated from protobuf service GrpcDocumentationGeneration
*/
export class GrpcDocumentationGenerationClient implements IGrpcDocumentationGenerationClient, ServiceInfo {
typeName = GrpcDocumentationGeneration.typeName;
methods = GrpcDocumentationGeneration.methods;
options = GrpcDocumentationGeneration.options;
constructor(private readonly _transport: RpcTransport) {
}
/**
* @generated from protobuf rpc: UserRequestFeatureDocumentation(FeatureDocumentationRequest) returns (ProcessData);
*/
userRequestFeatureDocumentation(input: FeatureDocumentationRequest, options?: RpcOptions): UnaryCall<FeatureDocumentationRequest, ProcessData> {
const method = this.methods[0], opt = this._transport.mergeOptions(options);
return stackIntercept<FeatureDocumentationRequest, ProcessData>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: RequestUserReviewDocumentationFromProcess(DocumentationContentRequest) returns (Empty);
*/
requestUserReviewDocumentationFromProcess(input: DocumentationContentRequest, options?: RpcOptions): UnaryCall<DocumentationContentRequest, Empty> {
const method = this.methods[1], opt = this._transport.mergeOptions(options);
return stackIntercept<DocumentationContentRequest, Empty>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: RequestUserReviewDocumentation(ProcessData) returns (stream DocumentationContentRequest);
*/
requestUserReviewDocumentation(input: ProcessData, options?: RpcOptions): ServerStreamingCall<ProcessData, DocumentationContentRequest> {
const method = this.methods[2], opt = this._transport.mergeOptions(options);
return stackIntercept<ProcessData, DocumentationContentRequest>("serverStreaming", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: UserReviewedDocumentation(DocumentationApprovalRequest) returns (Empty);
*/
userReviewedDocumentation(input: DocumentationApprovalRequest, options?: RpcOptions): UnaryCall<DocumentationApprovalRequest, Empty> {
const method = this.methods[3], opt = this._transport.mergeOptions(options);
return stackIntercept<DocumentationApprovalRequest, Empty>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: PublishDocumentation(DocumentationContentRequest) returns (Empty);
*/
publishDocumentation(input: DocumentationContentRequest, options?: RpcOptions): UnaryCall<DocumentationContentRequest, Empty> {
const method = this.methods[4], opt = this._transport.mergeOptions(options);
return stackIntercept<DocumentationContentRequest, Empty>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: ReceivePublishedDocumentation(ProcessData) returns (stream DocumentationContentRequest);
*/
receivePublishedDocumentation(input: ProcessData, options?: RpcOptions): ServerStreamingCall<ProcessData, DocumentationContentRequest> {
const method = this.methods[5], opt = this._transport.mergeOptions(options);
return stackIntercept<ProcessData, DocumentationContentRequest>("serverStreaming", this._transport, method, opt, input);
}
}
@@ -0,0 +1,385 @@
// @generated by protobuf-ts 2.9.6 with parameter generate_dependencies
// @generated from protobuf file "documentGeneration.proto" (syntax proto3)
// tslint:disable
import { ServiceType } from "@protobuf-ts/runtime-rpc";
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
import type { IBinaryWriter } from "@protobuf-ts/runtime";
import { WireType } from "@protobuf-ts/runtime";
import type { BinaryReadOptions } from "@protobuf-ts/runtime";
import type { IBinaryReader } from "@protobuf-ts/runtime";
import { UnknownFieldHandler } from "@protobuf-ts/runtime";
import type { PartialMessage } from "@protobuf-ts/runtime";
import { reflectionMergePartial } from "@protobuf-ts/runtime";
import { MessageType } from "@protobuf-ts/runtime";
/**
* @generated from protobuf message FeatureDocumentationRequest
*/
export interface FeatureDocumentationRequest {
/**
* @generated from protobuf field: string title = 1;
*/
title: string;
/**
* @generated from protobuf field: string userDescription = 2;
*/
userDescription: string;
/**
* @generated from protobuf field: string content = 3;
*/
content: string;
/**
* @generated from protobuf field: string processId = 10;
*/
processId: string;
}
/**
* @generated from protobuf message DocumentationContentRequest
*/
export interface DocumentationContentRequest {
/**
* @generated from protobuf field: string title = 1;
*/
title: string;
/**
* @generated from protobuf field: string content = 2;
*/
content: string;
/**
* @generated from protobuf field: string assistantMessage = 3;
*/
assistantMessage: string;
/**
* @generated from protobuf field: ProcessData processData = 10;
*/
processData?: ProcessData;
}
/**
* @generated from protobuf message DocumentationApprovalRequest
*/
export interface DocumentationApprovalRequest {
/**
* @generated from protobuf field: bool documentationApproved = 1;
*/
documentationApproved: boolean;
/**
* @generated from protobuf field: string reason = 2;
*/
reason: string;
/**
* @generated from protobuf field: ProcessData processData = 10;
*/
processData?: ProcessData;
}
/**
* @generated from protobuf message ProcessData
*/
export interface ProcessData {
/**
* @generated from protobuf field: string processId = 1;
*/
processId: string;
}
/**
* @generated from protobuf message Empty
*/
export interface Empty {
}
// @generated message type with reflection information, may provide speed optimized methods
class FeatureDocumentationRequest$Type extends MessageType<FeatureDocumentationRequest> {
constructor() {
super("FeatureDocumentationRequest", [
{ no: 1, name: "title", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 2, name: "userDescription", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "content", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 10, name: "processId", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<FeatureDocumentationRequest>): FeatureDocumentationRequest {
const message = globalThis.Object.create((this.messagePrototype!));
message.title = "";
message.userDescription = "";
message.content = "";
message.processId = "";
if (value !== undefined)
reflectionMergePartial<FeatureDocumentationRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FeatureDocumentationRequest): FeatureDocumentationRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string title */ 1:
message.title = reader.string();
break;
case /* string userDescription */ 2:
message.userDescription = reader.string();
break;
case /* string content */ 3:
message.content = reader.string();
break;
case /* string processId */ 10:
message.processId = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: FeatureDocumentationRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* string title = 1; */
if (message.title !== "")
writer.tag(1, WireType.LengthDelimited).string(message.title);
/* string userDescription = 2; */
if (message.userDescription !== "")
writer.tag(2, WireType.LengthDelimited).string(message.userDescription);
/* string content = 3; */
if (message.content !== "")
writer.tag(3, WireType.LengthDelimited).string(message.content);
/* string processId = 10; */
if (message.processId !== "")
writer.tag(10, WireType.LengthDelimited).string(message.processId);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message FeatureDocumentationRequest
*/
export const FeatureDocumentationRequest = new FeatureDocumentationRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class DocumentationContentRequest$Type extends MessageType<DocumentationContentRequest> {
constructor() {
super("DocumentationContentRequest", [
{ no: 1, name: "title", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 2, name: "content", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "assistantMessage", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 10, name: "processData", kind: "message", T: () => ProcessData }
]);
}
create(value?: PartialMessage<DocumentationContentRequest>): DocumentationContentRequest {
const message = globalThis.Object.create((this.messagePrototype!));
message.title = "";
message.content = "";
message.assistantMessage = "";
if (value !== undefined)
reflectionMergePartial<DocumentationContentRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DocumentationContentRequest): DocumentationContentRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string title */ 1:
message.title = reader.string();
break;
case /* string content */ 2:
message.content = reader.string();
break;
case /* string assistantMessage */ 3:
message.assistantMessage = reader.string();
break;
case /* ProcessData processData */ 10:
message.processData = ProcessData.internalBinaryRead(reader, reader.uint32(), options, message.processData);
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: DocumentationContentRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* string title = 1; */
if (message.title !== "")
writer.tag(1, WireType.LengthDelimited).string(message.title);
/* string content = 2; */
if (message.content !== "")
writer.tag(2, WireType.LengthDelimited).string(message.content);
/* string assistantMessage = 3; */
if (message.assistantMessage !== "")
writer.tag(3, WireType.LengthDelimited).string(message.assistantMessage);
/* ProcessData processData = 10; */
if (message.processData)
ProcessData.internalBinaryWrite(message.processData, writer.tag(10, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message DocumentationContentRequest
*/
export const DocumentationContentRequest = new DocumentationContentRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class DocumentationApprovalRequest$Type extends MessageType<DocumentationApprovalRequest> {
constructor() {
super("DocumentationApprovalRequest", [
{ no: 1, name: "documentationApproved", kind: "scalar", T: 8 /*ScalarType.BOOL*/ },
{ no: 2, name: "reason", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 10, name: "processData", kind: "message", T: () => ProcessData }
]);
}
create(value?: PartialMessage<DocumentationApprovalRequest>): DocumentationApprovalRequest {
const message = globalThis.Object.create((this.messagePrototype!));
message.documentationApproved = false;
message.reason = "";
if (value !== undefined)
reflectionMergePartial<DocumentationApprovalRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DocumentationApprovalRequest): DocumentationApprovalRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* bool documentationApproved */ 1:
message.documentationApproved = reader.bool();
break;
case /* string reason */ 2:
message.reason = reader.string();
break;
case /* ProcessData processData */ 10:
message.processData = ProcessData.internalBinaryRead(reader, reader.uint32(), options, message.processData);
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: DocumentationApprovalRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* bool documentationApproved = 1; */
if (message.documentationApproved !== false)
writer.tag(1, WireType.Varint).bool(message.documentationApproved);
/* string reason = 2; */
if (message.reason !== "")
writer.tag(2, WireType.LengthDelimited).string(message.reason);
/* ProcessData processData = 10; */
if (message.processData)
ProcessData.internalBinaryWrite(message.processData, writer.tag(10, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message DocumentationApprovalRequest
*/
export const DocumentationApprovalRequest = new DocumentationApprovalRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class ProcessData$Type extends MessageType<ProcessData> {
constructor() {
super("ProcessData", [
{ no: 1, name: "processId", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<ProcessData>): ProcessData {
const message = globalThis.Object.create((this.messagePrototype!));
message.processId = "";
if (value !== undefined)
reflectionMergePartial<ProcessData>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ProcessData): ProcessData {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string processId */ 1:
message.processId = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: ProcessData, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* string processId = 1; */
if (message.processId !== "")
writer.tag(1, WireType.LengthDelimited).string(message.processId);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message ProcessData
*/
export const ProcessData = new ProcessData$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Empty$Type extends MessageType<Empty> {
constructor() {
super("Empty", []);
}
create(value?: PartialMessage<Empty>): Empty {
const message = globalThis.Object.create((this.messagePrototype!));
if (value !== undefined)
reflectionMergePartial<Empty>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: Empty): Empty {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: Empty, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message Empty
*/
export const Empty = new Empty$Type();
/**
* @generated ServiceType for protobuf service GrpcDocumentationGeneration
*/
export const GrpcDocumentationGeneration = new ServiceType("GrpcDocumentationGeneration", [
{ name: "UserRequestFeatureDocumentation", options: {}, I: FeatureDocumentationRequest, O: ProcessData },
{ name: "RequestUserReviewDocumentationFromProcess", options: {}, I: DocumentationContentRequest, O: Empty },
{ name: "RequestUserReviewDocumentation", serverStreaming: true, options: {}, I: ProcessData, O: DocumentationContentRequest },
{ name: "UserReviewedDocumentation", options: {}, I: DocumentationApprovalRequest, O: Empty },
{ name: "PublishDocumentation", options: {}, I: DocumentationContentRequest, O: Empty },
{ name: "ReceivePublishedDocumentation", serverStreaming: true, options: {}, I: ProcessData, O: DocumentationContentRequest }
]);
@@ -0,0 +1,38 @@
syntax = "proto3";
option csharp_namespace = "ProcessWithCloudEvents.Grpc.DocumentationGenerator";
service GrpcDocumentationGeneration {
rpc UserRequestFeatureDocumentation (FeatureDocumentationRequest) returns (ProcessData);
rpc RequestUserReviewDocumentationFromProcess (DocumentationContentRequest) returns (Empty);
rpc RequestUserReviewDocumentation (ProcessData) returns (stream DocumentationContentRequest);
rpc UserReviewedDocumentation (DocumentationApprovalRequest) returns (Empty);
rpc PublishDocumentation (DocumentationContentRequest) returns (Empty);
rpc ReceivePublishedDocumentation (ProcessData) returns (stream DocumentationContentRequest);
}
message FeatureDocumentationRequest {
string title = 1;
string userDescription = 2;
string content = 3;
string processId = 10;
}
message DocumentationContentRequest {
string title = 1;
string content = 2;
string assistantMessage = 3;
ProcessData processData = 10;
}
message DocumentationApprovalRequest {
bool documentationApproved = 1;
string reason = 2;
ProcessData processData = 10;
}
message ProcessData {
string processId = 1;
}
message Empty {}
@@ -0,0 +1,5 @@
/*
* Copyright (c) 2025 Microsoft
* All rights reserved.
*/
/// <reference types="vite/client" />
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"noImplicitAny": false
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using Grpc.Net.Client;
using Microsoft.SemanticKernel;
using ProcessWithCloudEvents.Grpc.DocumentationGenerator;
using ProcessWithCloudEvents.Processes;
using ProcessWithCloudEvents.Processes.Models;
namespace ProcessWithCloudEvents.Grpc.Clients;
/// <summary>
/// Client that implements the <see cref="IExternalKernelProcessMessageChannel"/> interface used internally by the SK process
/// to emit events to external systems.<br/>
/// This implementation is an example of a gRPC client that emits events to a gRPC server
/// </summary>
public class DocumentGenerationGrpcClient : IExternalKernelProcessMessageChannel
{
private GrpcChannel? _grpcChannel;
private GrpcDocumentationGeneration.GrpcDocumentationGenerationClient? _grpcClient;
/// <inheritdoc/>
public async ValueTask Initialize()
{
this._grpcChannel = GrpcChannel.ForAddress("http://localhost:58641");
this._grpcClient = new GrpcDocumentationGeneration.GrpcDocumentationGenerationClient(this._grpcChannel);
}
/// <inheritdoc/>
public async ValueTask Uninitialize()
{
if (this._grpcChannel != null)
{
await this._grpcChannel.ShutdownAsync();
}
}
/// <inheritdoc/>
public async Task EmitExternalEventAsync(string externalTopicEvent, KernelProcessProxyMessage message)
{
if (this._grpcClient != null && message.EventData != null)
{
switch (externalTopicEvent)
{
case DocumentGenerationProcess.DocGenerationTopics.RequestUserReview:
var requestDocument = message.EventData.ToObject() as DocumentInfo;
if (requestDocument != null)
{
await this._grpcClient.RequestUserReviewDocumentationFromProcessAsync(new()
{
Title = requestDocument.Title,
AssistantMessage = "Document ready for user revision. Approve or reject document",
Content = requestDocument.Content,
ProcessData = new() { ProcessId = message.ProcessId }
});
}
return;
case DocumentGenerationProcess.DocGenerationTopics.PublishDocumentation:
var publishedDocument = message.EventData.ToObject() as DocumentInfo;
if (publishedDocument != null)
{
await this._grpcClient.PublishDocumentationAsync(new()
{
Title = publishedDocument.Title,
AssistantMessage = "Published Document Ready",
Content = publishedDocument.Content,
ProcessData = new() { ProcessId = message.ProcessId }
});
}
return;
}
}
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel.DataAnnotations;
namespace ProcessWithCloudEvents.Grpc.Extensions;
/// <summary>
/// Class with extension methods for app configuration.
/// </summary>
public static class ConfigurationExtensions
{
/// <summary>
/// Returns <typeparamref name="TOptions"/> if it's valid or throws <see cref="ValidationException"/>.
/// </summary>
public static TOptions GetValid<TOptions>(this IConfigurationRoot configurationRoot, string sectionName)
{
var options = configurationRoot.GetSection(sectionName).Get<TOptions>()!;
Validator.ValidateObject(options, new(options));
return options;
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel.DataAnnotations;
namespace ProcessWithCloudEvents.Grpc.Options;
/// <summary>
/// Configuration for OpenAI chat completion service.
/// </summary>
public class OpenAIOptions
{
public const string SectionName = "OpenAI";
[Required]
public string ChatModelId { get; set; } = string.Empty;
[Required]
public string ApiKey { get; set; } = string.Empty;
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>
$(NoWarn);CA2007,CS1591,CA1861,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0080,SKEXP0110
</NoWarn>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\documentationGenerator.proto" GrpcServices="Both" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Connectors\Connectors.OpenAI\Connectors.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Experimental\Process.Abstractions\Process.Abstractions.csproj" />
<ProjectReference Include="..\..\..\..\src\Experimental\Process.Core\Process.Core.csproj" />
<ProjectReference Include="..\..\..\..\src\Experimental\Process.Runtime.Dapr\Process.Runtime.Dapr.csproj" />
<ProjectReference Include="..\ProcessWithCloudEvents.Processes\ProcessWithCloudEvents.Processes.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapr.Actors" />
<PackageReference Include="Dapr.Actors.AspNetCore" />
<PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.AspNetCore" />
<PackageReference Include="Grpc.AspNetCore.Server.Reflection" />
<PackageReference Include="Grpc.AspNetCore.Web" />
<PackageReference Include="Grpc.Net.Client" />
<PackageReference Include="Grpc.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Folder Include="Media\" />
</ItemGroup>
</Project>
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel;
using ProcessWithCloudEvents.Grpc.Clients;
using ProcessWithCloudEvents.Grpc.Extensions;
using ProcessWithCloudEvents.Grpc.Options;
using ProcessWithCloudEvents.Grpc.Services;
var builder = WebApplication.CreateBuilder(args);
var config = new ConfigurationBuilder()
.AddUserSecrets<Program>()
.AddEnvironmentVariables()
.Build();
// Configure logging
builder.Services.AddLogging((logging) =>
{
logging.AddConsole();
logging.AddDebug();
});
var openAIOptions = config.GetValid<OpenAIOptions>(OpenAIOptions.SectionName);
// Configure the Kernel with DI. This is required for dependency injection to work with processes.
builder.Services.AddKernel();
builder.Services.AddOpenAIChatCompletion(openAIOptions.ChatModelId, openAIOptions.ApiKey);
builder.Services.AddSingleton<DocumentGenerationService>();
// Injecting SK Process custom grpc client IExternalKernelProcessMessageChannel implementation
builder.Services.AddSingleton<IExternalKernelProcessMessageChannel, DocumentGenerationGrpcClient>();
// Configure Dapr
builder.Services.AddActors(static options =>
{
// Register the actors required to run Processes
options.AddProcessActors();
});
// Enabling CORS for grpc-web when using webApp as client, remove if not needed
builder.Services.AddCors(o => o.AddPolicy("AllowAll", builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
}));
// Add grpc related services.
builder.Services.AddGrpc();
builder.Services.AddGrpcReflection();
var app = builder.Build();
app.UseCors();
// Grpc services mapping
// Enabling grpc-web, remove if not needed
app.UseGrpcWeb();
// Enabling CORS for grpc-web, remove if not needed
app.MapGrpcReflectionService().RequireCors("AllowAll");
app.MapGrpcService<DocumentGenerationService>().EnableGrpcWeb().RequireCors("AllowAll");
// Dapr actors related mapping
app.MapActorsHandlers();
app.Run();
@@ -0,0 +1,38 @@
syntax = "proto3";
option csharp_namespace = "ProcessWithCloudEvents.Grpc.DocumentationGenerator";
service GrpcDocumentationGeneration {
rpc UserRequestFeatureDocumentation (FeatureDocumentationRequest) returns (ProcessData);
rpc RequestUserReviewDocumentationFromProcess (DocumentationContentRequest) returns (Empty);
rpc RequestUserReviewDocumentation (ProcessData) returns (stream DocumentationContentRequest);
rpc UserReviewedDocumentation (DocumentationApprovalRequest) returns (Empty);
rpc PublishDocumentation (DocumentationContentRequest) returns (Empty);
rpc ReceivePublishedDocumentation (ProcessData) returns (stream DocumentationContentRequest);
}
message FeatureDocumentationRequest {
string title = 1;
string userDescription = 2;
string content = 3;
string processId = 10;
}
message DocumentationContentRequest {
string title = 1;
string content = 2;
string assistantMessage = 3;
ProcessData processData = 10;
}
message DocumentationApprovalRequest {
bool documentationApproved = 1;
string reason = 2;
ProcessData processData = 10;
}
message ProcessData {
string processId = 1;
}
message Empty {}
@@ -0,0 +1,131 @@
# Process With Cloud Events - using gRPC
For using gRPC, this demo follows the guidelines suggested for any [gRPC ASP.NET Core App](https://learn.microsoft.com/en-us/aspnet/core/grpc/test-tools?view=aspnetcore-9.0).
Which for this demo means:
- Making use of `builder.Services.AddGrpcReflection()` and `app.MapGrpcReflectionService()`
- Making use of [`gRPCui`](https://github.com/fullstorydev/grpcui) for testing
## Explanation
This demo showcases how SK Process Framework could interact with a gRPC Server and clients.
The main difference of this demo is the custom implementation of the gRPC Server and client used internally by the SK Process in the SK Proxy Step.
Main gRPC components:
- `documentationGenerator.proto`: `<root>\dotnet\samples\Demos\ProcessWithCloudEvents\ProcessWithCloudEvents.Grpc\Protos\documentationGenerator.proto`
- gRPC Server: `<root>\dotnet\samples\Demos\ProcessWithCloudEvents\ProcessWithCloudEvents.Grpc\Services\DocumentGenerationService.cs`
- gRPC Client/IExternalKernelProcessMessageChannel implementation: `<root>\dotnet\samples\Demos\ProcessWithCloudEvents\ProcessWithCloudEvents.Grpc\Clients\DocumentGenerationGrpcClient.cs`
### SK Process and gRPC Events
``` mermaid
sequenceDiagram
participant grpcClient as gRPC Client
box Server
participant grpcServer as gRPC Server
participant SKP as SK Process
end
grpcClient->>grpcServer: UserRequestFeatureDocumentation <br/>gRPC
grpcServer->>SKP: StartDocumentGeneration <br/>SK event
SKP->>grpcServer: RequestUserReview (SK Topic)/<br/>RequestUserReviewDocumentationFromProcess (gRPC)
grpcServer->>grpcClient: RequestUserReviewDocumentation <br/>gRPC
grpcClient->>grpcServer: UserReviewedDocumentation <br/>gRPC
grpcServer->>SKP: UserApprovedDocument/UserRejectedDocument <br/>SK event
SKP->>grpcServer: PublishDocumentation (SK Topic)/<br/>PublishDocumentation (gRPC)
grpcServer->>grpcClient: ReceivePublishedDocumentation <br/>gRPC
```
1. When the `UserRequestFeatureDocumentation` gRPC request is received from the gRPC client, the server initiates an SK Process and emits the `StartDocumentGeneration` SK event.
2. The `RequestUserReview` topic is emitted when the `DocumentationApproved` event is triggered during the `ProofReadDocumentationStep`. This event invokes the `RequestUserReviewDocumentationFromProcess` gRPC method to communicate with the server.
3. The `RequestUserReviewDocumentationFromProcess` method updates the shared stream, which is used to communicate with the subscribers of `RequestUserReviewDocumentation`. The gRPC client then receives the document for review and approval.
4. The gRPC client can approve or reject the document using the `UserReviewedDocumentation` method to communicate with the server. The server then sends the `UserApprovedDocument` or `UserRejectedDocument` SK event to the SK Process.
5. The SK Process resumes, and the `PublishDocumentationStep` now has all the necessary parameters to execute. Upon execution, the `PublishDocumentation` topic is triggered, invoking the `PublishDocumentation` method on the gRPC server.
6. The PublishDocumentation method updates the shared stream used by `ReceivePublishedDocumentation`, ensuring that all subscribers receive the update of the latest published document
## Demo
### Requirements
- Have Dapr setup ready
- Build and Run the app
- Interact with the server by:
- Install and run `gRPCui` listening to the address `localhost:58641`:
```
./grpcui.exe -plaintext localhost:58641
```
or
- Use the `ProcessWithCloudEvents.Client` App and use it to interact with the server. This app uses gRPC Web, which interacts with the server through `localhost:58640`.
### Usage without UI
For interacting with the gRPC server, the
1. Build and run the app
2. Open 2 windows of `gRPCui` with the following methods:
- Window 1:
- Method name: `UserRequestFeatureDocumentation` and `UserReviewedDocumentation`
- Window 2:
- Method name: `RequestUserReviewDocumentation`
- Window 3:
- Method name: `ReceivePublishedDocumentation`
3. Select a process id to be used with all methods. Example: processId = "100"
4. Execute different methods in the following order:
1. `RequestUserReviewDocumentation` with Request Data:
```json
{
"processId": "100"
}
```
This will subscribe to any request for review done for the specific process id and a response will be received when the process emits a notification.
Set timeout to 30 seconds.
2. `UserRequestFeatureDocumentation` with Request Data:
```json
{
"title": "some product title",
"userDescription": "some user description",
"content": "some product content",
"processId": "100",
}
```
This request will kickstart the creation of a new process with the specific processId passing an initial event to the SK process.
5. Once the `RequestUserReviewDocumentation` is received, execute the following methods:
1. `ReceivePublishedDocumentation` with Request Data:
```json
{
"processId": "100"
}
```
This will subscribe to any request for review done for the specific process id and a response will be received when the process emits a notification.
Set timeout to 30 seconds.
2. `UserReviewedDocumentation` with Request Data:
```json
{
"documentationApproved": true,
"reason": "",
"processData":
{
"processId": "100"
}
}
```
### Debugging
For debugging and be able to set breakpoints in different stages of the app, you can:
- Install the [Visual Studio Dapr Extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vs-dapr) and make use of it by making use of the `<root>\dotnet\dapr.yaml` file already in the repository.
or
- Set the `ProcessWithCloudEvents.Grpc` as startup app, run and attach the Visual Studio debugger:
```
dapr run --app-id processwithcloudevents-grpc --app-port 58640 --app-protocol http -- dotnet run --no-build
```
@@ -0,0 +1,194 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using Dapr.Actors.Client;
using Grpc.Core;
using Microsoft.SemanticKernel;
using Microsoft.VisualStudio.Threading;
using ProcessWithCloudEvents.Grpc.Clients;
using ProcessWithCloudEvents.Grpc.DocumentationGenerator;
using ProcessWithCloudEvents.Processes;
using ProcessWithCloudEvents.Processes.Models;
namespace ProcessWithCloudEvents.Grpc.Services;
/// <summary>
/// This gRPC service handles the generation of documents using/invoking a SK Process
/// </summary>
public class DocumentGenerationService : GrpcDocumentationGeneration.GrpcDocumentationGenerationBase
{
private readonly ILogger<DocumentGenerationService> _logger;
private readonly Kernel _kernel;
private readonly IActorProxyFactory _actorProxyFactory;
private readonly ConcurrentDictionary<string, ConcurrentBag<IServerStreamWriter<DocumentationContentRequest>>> _docReviewSubscribers;
private readonly ConcurrentDictionary<string, ConcurrentBag<IServerStreamWriter<DocumentationContentRequest>>> _publishDocumentSubscribers;
/// <summary>
/// Constructor for the <see cref="DocumentGenerationService"/>
/// </summary>
/// <param name="logger"></param>
/// <param name="kernel"></param>
/// <param name="actorProxy"></param>
public DocumentGenerationService(ILogger<DocumentGenerationService> logger, Kernel kernel, IActorProxyFactory actorProxy)
{
this._logger = logger;
this._kernel = kernel;
this._actorProxyFactory = actorProxy;
this._docReviewSubscribers = new();
this._publishDocumentSubscribers = new();
}
/// <summary>
/// Method that receives a request to generate documentation, this will start the SK process
/// defined in <see cref="DocumentGenerationProcess.CreateProcessBuilder"/> <br/>
/// It will use the processId passed in the request or generate a new one if not provided
/// </summary>
/// <param name="request"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task<ProcessData> UserRequestFeatureDocumentation(FeatureDocumentationRequest request, ServerCallContext context)
{
var processId = string.IsNullOrEmpty(request.ProcessId) ? Guid.NewGuid().ToString() : request.ProcessId;
var process = DocumentGenerationProcess.CreateProcessBuilder().Build();
var processContext = await process.StartAsync(new KernelProcessEvent()
{
Id = DocumentGenerationProcess.DocGenerationEvents.StartDocumentGeneration,
// The object ProductInfo is sent because this is the type the GatherProductInfoStep is expecting
Data = new ProductInfo() { Title = request.Title, Content = request.Content, UserInput = request.UserDescription },
},
processId,
this._actorProxyFactory);
return new ProcessData { ProcessId = processId };
}
/// <summary>
/// Method that receives a request to request user review of documentation, this will send a request to the client
/// if subscribed to the <see cref="RequestUserReviewDocumentation"/> method previously with the same process id.<br/>
/// This method is meant to be used within the SK process from the <see cref="DocumentGenerationGrpcClient"/> implementation.
/// </summary>
/// <param name="request"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task<Empty> RequestUserReviewDocumentationFromProcess(DocumentationContentRequest request, ServerCallContext context)
{
if (this._docReviewSubscribers.TryGetValue(request.ProcessData.ProcessId, out var subscribers))
{
foreach (var subscriber in subscribers)
{
await subscriber.WriteAsync(request).ConfigureAwait(false);
}
}
return new Empty();
}
/// <summary>
/// Method that receives request to receive user review of documentation. <br/>
/// This is meant to be used by the external client
/// </summary>
/// <param name="request"></param>
/// <param name="responseStream"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task RequestUserReviewDocumentation(ProcessData request, IServerStreamWriter<DocumentationContentRequest> responseStream, ServerCallContext context)
{
var subscribers = this._docReviewSubscribers.GetOrAdd(request.ProcessId, []);
subscribers.Add(responseStream);
try
{
// Wait until the client disconnects
await context.CancellationToken.WaitHandle.ToTask();
}
finally
{
// Remove the subscriber when client disconnects
#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type.
subscribers.TryTake(out responseStream);
#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type.
}
}
/// <summary>
/// Method that receives a request to approve or reject documentation, this will send the response to the SK process.
/// This is meant to be used by the external client.
/// </summary>
/// <param name="request"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task<Empty> UserReviewedDocumentation(DocumentationApprovalRequest request, ServerCallContext context)
{
var process = DocumentGenerationProcess.CreateProcessBuilder().Build();
KernelProcessEvent processEvent;
if (request.DocumentationApproved)
{
processEvent = new()
{
Id = DocumentGenerationProcess.DocGenerationEvents.UserApprovedDocument,
Data = true,
};
}
else
{
processEvent = new()
{
Id = DocumentGenerationProcess.DocGenerationEvents.UserRejectedDocument,
Data = request.Reason,
};
}
var processContext = await process.StartAsync(processEvent, request.ProcessData.ProcessId);
return new Empty();
}
/// <summary>
/// Method used to publish the generated documentation, this will send the documentation to the client
/// if subscribed to the <see cref="ReceivePublishedDocumentation"/> method with the same process id.<br/>
/// This method is meant to be used within the SK process from the <see cref="DocumentGenerationGrpcClient"/> implementation.
/// </summary>
/// <param name="request"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task<Empty> PublishDocumentation(DocumentationContentRequest request, ServerCallContext context)
{
if (this._publishDocumentSubscribers.TryGetValue(request.ProcessData.ProcessId, out var subscribers))
{
foreach (var subscriber in subscribers)
{
await subscriber.WriteAsync(request).ConfigureAwait(false);
}
}
return new Empty();
}
/// <summary>
/// Method that receives request to receive published documentation from a specific process id.
/// This is meant to be used by the external client.
/// </summary>
/// <param name="request"></param>
/// <param name="responseStream"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task ReceivePublishedDocumentation(ProcessData request, IServerStreamWriter<DocumentationContentRequest> responseStream, ServerCallContext context)
{
var subscribers = this._publishDocumentSubscribers.GetOrAdd(request.ProcessId, []);
subscribers.Add(responseStream);
try
{
// Wait until the client disconnects
await context.CancellationToken.WaitHandle.ToTask();
}
finally
{
// Remove the subscriber when client disconnects
#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type.
subscribers.TryTake(out responseStream);
#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type.
}
}
}
@@ -0,0 +1,21 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http1": {
"Url": "http://*:58640",
"Protocols": "Http1"
},
"Http2": {
"Url": "http://*:58641",
"Protocols": "Http2"
}
}
}
}
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel;
using ProcessWithCloudEvents.Processes.Steps;
namespace ProcessWithCloudEvents.Processes;
/// <summary>
/// Components related to the SK Process for generating documentation
/// </summary>
public static class DocumentGenerationProcess
{
/// <summary>
/// SK Process events emitted by <see cref="DocumentGenerationProcess"/>
/// </summary>
public static class DocGenerationEvents
{
/// <summary>
/// Event to start the document generation process
/// </summary>
public const string StartDocumentGeneration = nameof(StartDocumentGeneration);
/// <summary>
/// Event emitted when the user rejects the document
/// </summary>
public const string UserRejectedDocument = nameof(UserRejectedDocument);
/// <summary>
/// Event emitted when the user approves the document
/// </summary>
public const string UserApprovedDocument = nameof(UserApprovedDocument);
}
/// <summary>
/// SK Process topics emitted by <see cref="DocumentGenerationProcess"/>
/// Topics are used to emit events to external systems
/// </summary>
public static class DocGenerationTopics
{
/// <summary>
/// Request user review document generation topic
/// </summary>
public const string RequestUserReview = nameof(RequestUserReview);
/// <summary>
/// Publish documentat generated topic
/// </summary>
public const string PublishDocumentation = nameof(PublishDocumentation);
}
/// <summary>
/// Creates a process builder for the Document Generation SK Process
/// </summary>
/// <param name="processName">name of the SK Process</param>
/// <returns>instance of <see cref="ProcessBuilder"/></returns>
public static ProcessBuilder CreateProcessBuilder(string processName = "DocumentationGeneration")
{
// Create the process builder
ProcessBuilder processBuilder = new(processName);
// Add the steps
var infoGatheringStep = processBuilder.AddStepFromType<GatherProductInfoStep>();
var docsGenerationStep = processBuilder.AddStepFromType<GenerateDocumentationStep>();
var docsProofreadStep = processBuilder.AddStepFromType<ProofReadDocumentationStep>();
var docsPublishStep = processBuilder.AddStepFromType<PublishDocumentationStep>();
var proxyStep = processBuilder.AddProxyStep(id: processName, [DocGenerationTopics.RequestUserReview, DocGenerationTopics.PublishDocumentation]);
// Orchestrate the external input events
processBuilder
.OnInputEvent(DocGenerationEvents.StartDocumentGeneration)
.SendEventTo(new(infoGatheringStep));
processBuilder
.OnInputEvent(DocGenerationEvents.UserRejectedDocument)
.SendEventTo(new(docsGenerationStep, functionName: GenerateDocumentationStep.ProcessFunctions.ApplySuggestions));
processBuilder
.OnInputEvent(DocGenerationEvents.UserApprovedDocument)
.SendEventTo(new(docsPublishStep, parameterName: "userApproval"));
// Hooking up the rest of the process steps
infoGatheringStep
.OnFunctionResult()
.SendEventTo(new ProcessFunctionTargetBuilder(docsGenerationStep, functionName: GenerateDocumentationStep.ProcessFunctions.GenerateDocs));
docsGenerationStep
.OnEvent(GenerateDocumentationStep.OutputEvents.DocumentationGenerated)
.SendEventTo(new ProcessFunctionTargetBuilder(docsProofreadStep));
docsProofreadStep
.OnEvent(ProofReadDocumentationStep.OutputEvents.DocumentationRejected)
.SendEventTo(new ProcessFunctionTargetBuilder(docsGenerationStep, functionName: GenerateDocumentationStep.ProcessFunctions.ApplySuggestions));
// When the proofreader approves the documentation, send it to the 'docs' parameter of the docsPublishStep
// Additionally, the generated document is emitted externally for user approval using the pre-configured proxyStep
docsProofreadStep
.OnEvent(ProofReadDocumentationStep.OutputEvents.DocumentationApproved)
.EmitExternalEvent(proxyStep, DocGenerationTopics.RequestUserReview)
.SendEventTo(new ProcessFunctionTargetBuilder(docsPublishStep, parameterName: "document"));
// When event is approved by user, it gets published externally too
docsPublishStep
.OnFunctionResult()
.EmitExternalEvent(proxyStep, DocGenerationTopics.PublishDocumentation);
return processBuilder;
}
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
namespace ProcessWithCloudEvents.Processes.Models;
/// <summary>
/// Object used to store generated document data
/// Since this object is used as parameter and state type by multiple steps,
/// Its members must be public and serializable
/// </summary>
//[DataContract]
public class DocumentInfo
{
/// <summary>
/// Id of the document
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Title of the document
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Content of the document
/// </summary>
public string Content { get; set; } = string.Empty;
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using ProcessWithCloudEvents.Processes.Steps;
namespace ProcessWithCloudEvents.Processes.Models;
/// <summary>
/// Object used in the <see cref="GatherProductInfoStep"/>
/// </summary>
public class ProductInfo
{
/// <summary>
/// Title of the product
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Content of the product
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// User comments
/// </summary>
public string UserInput { get; set; } = string.Empty;
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>
$(NoWarn);CA2007,CA1861,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0080,SKEXP0110
</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Connectors\Connectors.OpenAI\Connectors.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Experimental\Process.Abstractions\Process.Abstractions.csproj" />
<ProjectReference Include="..\..\..\..\src\Experimental\Process.Core\Process.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel;
using ProcessWithCloudEvents.Processes.Models;
namespace ProcessWithCloudEvents.Processes.Steps;
/// <summary>
/// Step that receives product information
/// </summary>
public class GatherProductInfoStep : KernelProcessStep
{
/// <summary>
/// Only step function that process the information passed
/// When there is only one function, there is no need to specify functionNames in the KernelFunction annotator
/// </summary>
/// <param name="productInfo">product information</param>
/// <returns></returns>
[KernelFunction]
public ProductInfo OnReceiveUserRequest(ProductInfo productInfo)
{
Console.WriteLine($"[{nameof(GatherProductInfoStep)}]:\tGathering product information for product named {productInfo.Title}");
// For example purposes we just return some fictional information.
productInfo.Content = """
Product Description:
GlowBrew is a revolutionary AI driven coffee machine with industry leading number of LEDs and programmable light shows. The machine is also capable of brewing coffee and has a built in grinder.
Product Features:
1. **Luminous Brew Technology**: Customize your morning ambiance with programmable LED lights that sync with your brewing process.
2. **AI Taste Assistant**: Learns your taste preferences over time and suggests new brew combinations to explore.
3. **Gourmet Aroma Diffusion**: Built-in aroma diffusers enhance your coffee's scent profile, energizing your senses before the first sip.
Troubleshooting:
- **Issue**: LED Lights Malfunctioning
- **Solution**: Reset the lighting settings via the app. Ensure the LED connections inside the GlowBrew are secure. Perform a factory reset if necessary.
""";
return productInfo;
}
}
@@ -0,0 +1,137 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using ProcessWithCloudEvents.Processes.Models;
namespace ProcessWithCloudEvents.Processes.Steps;
/// <summary>
/// Step that generates document content
/// </summary>
public class GenerateDocumentationStep : KernelProcessStep<GenerateDocumentationState>
{
/// <summary>
/// Function names of the steps, to be refereced when hooking up the step in a SK process
/// </summary>
public static class ProcessFunctions
{
/// <summary>
/// Genereta Doc function name
/// </summary>
public const string GenerateDocs = nameof(GenerateDocs);
/// <summary>
/// Apply Suggestions function name
/// </summary>
public const string ApplySuggestions = nameof(ApplySuggestions);
}
/// <summary>
/// Output events of the step, using this since 2 steps emit the same output event
/// </summary>
public static class OutputEvents
{
/// <summary>
/// Document Generated output event
/// </summary>
public const string DocumentationGenerated = nameof(DocumentationGenerated);
}
internal GenerateDocumentationState _state = new();
private readonly string _systemPrompt =
"""
Your job is to write high quality and engaging customer facing documentation for a new product from Contoso. You will be provide with information
about the product in the form of internal documentation, specs, and troubleshooting guides and you must use this information and
nothing else to generate the documentation. If suggestions are provided on the documentation you create, take the suggestions into account and
rewrite the documentation. Make sure the product sounds amazing.
""";
/// <inheritdoc/>
public override ValueTask ActivateAsync(KernelProcessStepState<GenerateDocumentationState> state)
{
this._state = state.State!;
this._state.ChatHistory ??= new ChatHistory(this._systemPrompt);
return base.ActivateAsync(state);
}
/// <summary>
/// Function that generates documentation from the <see cref="ProductInfo"/> provided
/// </summary>
/// <param name="kernel">instance of <see cref="Kernel"/></param>
/// <param name="context">instance of <see cref="KernelProcessStepContext"/></param>
/// <param name="productInfo">content to be used for document generation</param>
/// <returns></returns>
[KernelFunction(ProcessFunctions.GenerateDocs)]
public async Task GenerateDocumentationAsync(Kernel kernel, KernelProcessStepContext context, ProductInfo productInfo)
{
Console.WriteLine($"[{nameof(GenerateDocumentationStep)}]:\tGenerating documentation for provided productInfo...");
// Add the new product info to the chat history
this._state.ChatHistory!.AddUserMessage($"Product Info:\n{productInfo.Title} - {productInfo.Content}");
// Get a response from the LLM
IChatCompletionService chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
var generatedDocumentationResponse = await chatCompletionService.GetChatMessageContentAsync(this._state.ChatHistory!);
DocumentInfo generatedContent = new()
{
Id = Guid.NewGuid().ToString(),
Title = $"Generated document - {productInfo.Title}",
Content = generatedDocumentationResponse.Content!,
};
this._state!.LastGeneratedDocument = generatedContent;
await context.EmitEventAsync(OutputEvents.DocumentationGenerated, generatedContent);
}
/// <summary>
/// Function that integrates suggestion into document content
/// </summary>
/// <param name="kernel">instance of <see cref="Kernel"/></param>
/// <param name="context">instance of <see cref="KernelProcessStepContext"/></param>
/// <param name="suggestions">suggestions to be integrated into the document content</param>
/// <returns></returns>
[KernelFunction(ProcessFunctions.ApplySuggestions)]
public async Task ApplySuggestionsAsync(Kernel kernel, KernelProcessStepContext context, string suggestions)
{
Console.WriteLine($"[{nameof(GenerateDocumentationStep)}]:\tRewriting documentation with provided suggestions...");
// Add the new product info to the chat history
this._state.ChatHistory!.AddUserMessage($"Rewrite the documentation with the following suggestions:\n\n{suggestions}");
// Get a response from the LLM
IChatCompletionService chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
var generatedDocumentationResponse = await chatCompletionService.GetChatMessageContentAsync(this._state.ChatHistory!);
DocumentInfo updatedContent = new()
{
Id = Guid.NewGuid().ToString(),
Title = $"Revised - {this._state?.LastGeneratedDocument.Title}",
Content = generatedDocumentationResponse.Content!,
};
this._state!.LastGeneratedDocument = updatedContent;
await context.EmitEventAsync(OutputEvents.DocumentationGenerated, updatedContent);
}
}
/// <summary>
/// State of <see cref="GenerateDocumentationStep"/>
/// State must be saved since data is shared across functions
/// </summary>
public sealed class GenerateDocumentationState
{
/// <summary>
/// Last Document generated data
/// </summary>
public DocumentInfo LastGeneratedDocument { get; set; } = new();
/// <summary>
/// Chat history
/// </summary>
public ChatHistory? ChatHistory { get; set; }
}
@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Text.Json;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using ProcessWithCloudEvents.Processes.Models;
namespace ProcessWithCloudEvents.Processes.Steps;
/// <summary>
/// Step that determines generated document readiness
/// </summary>
public class ProofReadDocumentationStep : KernelProcessStep
{
/// <summary>
/// SK Process Events emitted by <see cref="ProofReadDocumentationStep"/>
/// </summary>
public static class OutputEvents
{
/// <summary>
/// Document has errors and needs to be revised event
/// </summary>
public const string DocumentationRejected = nameof(DocumentationRejected);
/// <summary>
/// Document looks ok and can be processed by the next step
/// </summary>
public const string DocumentationApproved = nameof(DocumentationApproved);
}
private readonly string _systemPrompt = """"
Your job is to proofread customer facing documentation for a new product from Contoso. You will be provide with proposed documentation
for a product and you must do the following things:
1. Determine if the documentation is passes the following criteria:
1. Documentation must use a professional tone.
1. Documentation should be free of spelling or grammar mistakes.
1. Documentation should be free of any offensive or inappropriate language.
1. Documentation should be technically accurate.
2. If the documentation does not pass 1, you must write detailed feedback of the changes that are needed to improve the documentation.
"""";
/// <summary>
/// Determines whether the document is needs a revision or is ready to be processed by the next step
/// </summary>
/// <param name="kernel">instance of <see cref="Kernel"/></param>
/// <param name="context">instance of <see cref="KernelProcessStepContext"/></param>
/// <param name="document">document content that is verified</param>
/// <returns></returns>
[KernelFunction]
public async Task ProofreadDocumentationAsync(Kernel kernel, KernelProcessStepContext context, DocumentInfo document)
{
var chatHistory = new ChatHistory(this._systemPrompt);
chatHistory.AddUserMessage(document.Content);
// Use structured output to ensure the response format is easily parsable
var settings = new OpenAIPromptExecutionSettings()
{
ResponseFormat = typeof(ProofreadingResponse)
};
IChatCompletionService chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
var proofreadResponse = await chatCompletionService.GetChatMessageContentAsync(chatHistory, executionSettings: settings);
var formattedResponse = JsonSerializer.Deserialize<ProofreadingResponse>(proofreadResponse.Content!);
Console.WriteLine($"[{nameof(ProofReadDocumentationStep)}]:\n\tGrade = {(formattedResponse!.MeetsExpectations ? "Pass" : "Fail")}\n\tExplanation = {formattedResponse.Explanation}\n\tSuggestions = {string.Join("\n\t\t", formattedResponse.Suggestions)}");
if (formattedResponse.MeetsExpectations)
{
// Events that are getting piped to steps that will be resumed, like PublishDocumentationStep.OnPublishDocumentation
// require events to be marked as public so they are persisted and restored correctly
await context.EmitEventAsync(OutputEvents.DocumentationApproved, data: document, visibility: KernelProcessEventVisibility.Public);
}
else
{
await context.EmitEventAsync(new()
{
Id = OutputEvents.DocumentationRejected,
// This event is getting piped to the GenerateDocumentationStep.ApplySuggestionsAsync step which expects a string with suggestions for the document
Data = $"Explanation = {formattedResponse.Explanation}, Suggestions = {string.Join(",", formattedResponse.Suggestions)} ",
});
}
}
private sealed class ProofreadingResponse
{
[Description("Specifies if the proposed documentation meets the expected standards for publishing.")]
public bool MeetsExpectations { get; set; }
[Description("An explanation of why the documentation does or does not meet expectations.")]
public string Explanation { get; set; } = "";
[Description("A lis of suggestions, may be empty if there no suggestions for improvement.")]
public List<string> Suggestions { get; set; } = [];
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel;
using ProcessWithCloudEvents.Processes.Models;
namespace ProcessWithCloudEvents.Processes.Steps;
/// <summary>
/// Step that publishes the generated documentation
/// </summary>
public class PublishDocumentationStep : KernelProcessStep
{
/// <summary>
/// Function that publishes the generated documentation
/// </summary>
/// <param name="document">document to be published</param>
/// <param name="userApproval">approval from the user</param>
/// <returns><see cref="DocumentInfo"/></returns>
[KernelFunction]
public DocumentInfo OnPublishDocumentation(DocumentInfo document, bool userApproval)
{
if (userApproval)
{
// For example purposes we just write the generated docs to the console
Console.WriteLine($"[{nameof(PublishDocumentationStep)}]:\tPublishing product documentation approved by user: \n{document.Title}\n{document.Content}");
}
return document;
}
}
@@ -0,0 +1,70 @@
# Process With Cloud Events
The following demos describe how to use the SK Process Framework to emit and receive cloud events.
| Project | Description |
| --- | --- |
| ProcessWithCloudEvents.Processes | Project that contains Process Builders definitions, related steps, models and structures independent of runtime |
| ProcessWithCloudEvents.Grpc | Project that contains a gRPC server using DAPR, that interacts with processes defined in the Processes project using gRPC |
| ProcessWithCloudEvents.Client | Project that contains a ReactJS App to showcase sending and receiving cloud events to and from a running SK Process in a server |
## Processes
### Document Generation Process
This SK process emulates the interaction of a user requesting for some document generation for a specific product. This includes:
1. **Gather Product Info Step**: Product Information Fetching
2. **Generate Documentation Step - `GenerateDocs`**: Document Generation
3. **Proof Read Documentation Step**: Document Proof Reading to validate the generate document
4. **Proxy Step**: Request for user approval of the generated document
5. **Publish Documentation Step**: Publish the generated document once the user approves it
6. **Generate Documentation Step - `ApplySuggestions`**: Document suggestions addition if the user rejects the generated document
7. **Proxy Step**: Publish generated document externally
``` mermaid
graph LR
StartDocumentGeneration([StartDocumentGeneration<br/>Event])
UserRejectedDocument([UserRejectedDocument<br/>Event])
UserApprovedDocument([UserApprovedDocument<br/>Event])
GatherProductInfo["Gather Product Info <br/> Step"]
GenerateDocs["Generate Documentation <br/> Step"]
ProofReadDocs["Proof Read Documentation <br/> Step"]
Proxy["Proxy <br/> Step"]
PublishDocs["Publish Documentation <br/> Step"]
GatherProductInfo --> GenerateDocs --> |DocumentGenerated| ProofReadDocs --> PublishDocs
ProofReadDocs --> |DocumentApproved| Proxy
ProofReadDocs -->|DocumentRejected| GenerateDocs
PublishDocs --> Proxy
StartDocumentGeneration --> GatherProductInfo
UserRejectedDocument --> GenerateDocs
UserApprovedDocument --> PublishDocs
```
- To emit events from the SK Process externally, SK events are sent to the Proxy Step.
- To receive external events and send them to the SK Process, SK Input Events are linked externally and sent to the process.
## Setup
1. A custom server is created that launches the creation of a SK Process with a specific process id and a specific input event.
2. A custom implementation of the `IExternalKernelProcessMessageChannel` is injected containing the custom implementation of the Cloud Event channel to be used. The custom implementation must include:
- `Initialize`: Initial setup to start the connection with the server.
- `Uninitialize`: Logic needed to close the connection with the server.
- `EmitExternalEventAsync`: Logic to send an external event to the server. This may include internal mapping of SK topics to specific server exposed methods.
3. Use of the `ProxyStep` in the `ProcessBuilder` to emit external events on specific SK Events. <br/>Example:
``` csharp
var proxyStep = processBuilder.AddProxyStep([DocGenerationTopics.RequestUserReview, DocGenerationTopics.PublishDocumentation]);
...
docsPublishStep
.OnFunctionResult()
.EmitExternalEvent(proxyStep, DocGenerationTopics.PublishDocumentation);
```
## Usage
1. Run the server running the SK Process using a specific Cloud Event technology
2. Launch the Client App to interact with the SK Process from a UI