diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
index 15c7f60..05d13a1 100644
--- a/.github/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE.md
@@ -1,10 +1,11 @@
-> Please provide us with the following information:
-> ---------------------------------------------------------------
+
+> ## Please provide us with the following information:
### This issue is for a: (mark with an `x`)
+
```
- [ ] bug report -> please search issues before submitting
- [ ] feature request
@@ -13,21 +14,27 @@ IF SUFFICIENT INFORMATION IS NOT PROVIDED VIA THE FOLLOWING TEMPLATE THE ISSUE M
```
### Minimal steps to reproduce
+
>
### Any log messages given by the failure
+
>
### Expected/desired behavior
+
>
### OS and Version?
+
> Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?)
### Versions
+
>
### Mention any other details that might be useful
-> ---------------------------------------------------------------
+> ---
+>
> Thanks! We'll be in touch soon.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index ab05e29..e157237 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,18 +1,24 @@
## Purpose
+
-* ...
+
+- ...
## Does this introduce a breaking change?
+
+
```
[ ] Yes
[ ] No
```
## Pull Request Type
+
What kind of change does this Pull Request introduce?
+
```
[ ] Bugfix
[ ] Feature
@@ -23,7 +29,8 @@ What kind of change does this Pull Request introduce?
```
## How to Test
-* Get the code
+
+- Get the code
```
git clone [repo-address]
@@ -32,14 +39,19 @@ git checkout [branch-name]
npm install
```
-* Test the code
+- Test the code
+
```
+
```
## What to Check
+
Verify that the following are valid
-* ...
+
+- ...
## Other Information
-
\ No newline at end of file
+
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a9115cf..884465a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing to [project-title]
-This project welcomes contributions and suggestions. Most contributions require you to agree to a
+This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
@@ -12,61 +12,67 @@ This project has adopted the [Microsoft Open Source Code of Conduct](https://ope
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
- - [Code of Conduct](#coc)
- - [Issues and Bugs](#issue)
- - [Feature Requests](#feature)
- - [Submission Guidelines](#submit)
+- [Code of Conduct](#coc)
+- [Issues and Bugs](#issue)
+- [Feature Requests](#feature)
+- [Submission Guidelines](#submit)
## Code of Conduct
+
Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
## Found an Issue?
+
If you find a bug in the source code or a mistake in the documentation, you can help us by
[submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can
[submit a Pull Request](#submit-pr) with a fix.
## Want a Feature?
-You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub
-Repository. If you would like to *implement* a new feature, please submit an issue with
+
+You can _request_ a new feature by [submitting an issue](#submit-issue) to the GitHub
+Repository. If you would like to _implement_ a new feature, please submit an issue with
a proposal for your work first, to be sure that we can use it.
-* **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr).
+- **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr).
## Submission Guidelines
### Submitting an Issue
+
Before you submit an issue, search the archive, maybe your question was already answered.
If your issue appears to be a bug, and hasn't been reported, open a new issue.
Help us to maximize the effort we can spend fixing issues and adding new
-features, by not reporting duplicate issues. Providing the following information will increase the
+features, by not reporting duplicate issues. Providing the following information will increase the
chances of your issue being dealt with quickly:
-* **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps
-* **Version** - what version is affected (e.g. 0.1.2)
-* **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you
-* **Browsers and Operating System** - is this a problem with all browsers?
-* **Reproduce the Error** - provide a live example or a unambiguous set of steps
-* **Related Issues** - has a similar issue been reported before?
-* **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be
+- **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps
+- **Version** - what version is affected (e.g. 0.1.2)
+- **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you
+- **Browsers and Operating System** - is this a problem with all browsers?
+- **Reproduce the Error** - provide a live example or a unambiguous set of steps
+- **Related Issues** - has a similar issue been reported before?
+- **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be
causing the problem (line of code or commit)
You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new].
### Submitting a Pull Request (PR)
+
Before you submit your Pull Request (PR) consider the following guidelines:
-* Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR
+- Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR
that relates to your submission. You don't want to duplicate effort.
-* Make your changes in a new git fork:
+- Make your changes in a new git fork:
+
+- Commit your changes using a descriptive commit message
+- Push your fork to GitHub:
+- In GitHub, create a pull request
+- If we suggest changes then:
-* Commit your changes using a descriptive commit message
-* Push your fork to GitHub:
-* In GitHub, create a pull request
-* If we suggest changes then:
- * Make the required updates.
- * Rebase your fork and force push to your GitHub repository (this will update your Pull Request):
+ - Make the required updates.
+ - Rebase your fork and force push to your GitHub repository (this will update your Pull Request):
```shell
git rebase master -i
diff --git a/README.md b/README.md
index 5e7cbd1..a4fc011 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,3 @@
# 🤖⚡ serverless-ai-langchainjs
This sample shows how to build a serverless ChatGPT-like with Retrieval-Augmented Generation AI application using [Azure Static Web Apps](https://learn.microsoft.com/azure/static-web-apps/overview), [Azure Functions](https://learn.microsoft.com/azure/azure-functions/functions-overview?pivots=programming-language-javascript) and the [LangChain.js library](https://js.langchain.com/). This can be used as a starting point for building more complex AI applications.
-
diff --git a/package-lock.json b/package-lock.json
index a13aca7..d0c5dc3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "serverless-ai-langchainjs",
"version": "1.0.0",
"license": "MIT",
+ "workspaces": ["packages/*"],
"devDependencies": {
"lint-staged": "^15.2.2",
"prettier": "^3.0.3",
diff --git a/package.json b/package.json
index 4f70b91..20b3a3e 100644
--- a/package.json
+++ b/package.json
@@ -7,9 +7,11 @@
"scripts": {
"start": "echo not implemented",
"start:webapp": "npm run start --workspace=webapp",
- "start:api": "npm run start --workspace=api",
+ "start:api": "npm run dev --workspace=api",
"build": "npm run build --workspaces --if-present",
"clean": "echo not implemented",
+ "lint": "xo",
+ "lint:fix": "xo --fix",
"format": "prettier --list-different --write .",
"prepare": "simple-git-hooks || echo 'simple-git-hooks install skipped'"
},
@@ -41,8 +43,7 @@
"pre-commit": "npx lint-staged"
},
"lint-staged": {
- "*.{js,ts}": "xo --fix",
- "*": "prettier --write"
+ "*.{js,ts,json,md,yaml,yml,html,css}": "prettier --write"
},
"prettier": {
"tabWidth": 2,
diff --git a/packages/webapp/README.md b/packages/webapp/README.md
new file mode 100644
index 0000000..2623ae8
--- /dev/null
+++ b/packages/webapp/README.md
@@ -0,0 +1,16 @@
+# Chat webapp
+
+This project uses [Vite](https://vitejs.dev/) as a frontend build tool, and [Lit](https://lit.dev/) as a web components library.
+
+## Available Scripts
+
+In the project directory, you can run:
+
+### `npm run dev`
+
+To start the app in dev mode.\
+Open [http://localhost:8000](http://localhost:8000) to view it in the browser.
+
+### `npm run build`
+
+To build the app for production to the `dist` folder.
diff --git a/packages/webapp/assets/lightbulb.svg b/packages/webapp/assets/lightbulb.svg
new file mode 100644
index 0000000..e5954c4
--- /dev/null
+++ b/packages/webapp/assets/lightbulb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/webapp/assets/new-chat.svg b/packages/webapp/assets/new-chat.svg
new file mode 100644
index 0000000..7f47869
--- /dev/null
+++ b/packages/webapp/assets/new-chat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/webapp/assets/question.svg b/packages/webapp/assets/question.svg
new file mode 100644
index 0000000..229b59d
--- /dev/null
+++ b/packages/webapp/assets/question.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/webapp/assets/send.svg b/packages/webapp/assets/send.svg
new file mode 100644
index 0000000..fab2fc8
--- /dev/null
+++ b/packages/webapp/assets/send.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/webapp/index.html b/packages/webapp/index.html
new file mode 100644
index 0000000..612eae5
--- /dev/null
+++ b/packages/webapp/index.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+ ChatGPT with Enterprise Data
+
+
+
+
+
+
+
+
diff --git a/packages/webapp/package.json b/packages/webapp/package.json
new file mode 100644
index 0000000..6c3211c
--- /dev/null
+++ b/packages/webapp/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "webapp",
+ "version": "1.0.0",
+ "description": "Web app for the serverless ChatGPT RAG sample",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 8000 --host",
+ "build": "vite build",
+ "watch": "vite build --watch --minify false",
+ "lint": "lit-analyzer",
+ "clean": "npx rimraf dist"
+ },
+ "author": "Microsoft",
+ "license": "MIT",
+ "dependencies": {
+ "lit": "^3.0.0"
+ },
+ "devDependencies": {
+ "lit-analyzer": "^2.0.1",
+ "vite": "^5.0.12"
+ },
+ "files": ["dist"]
+}
diff --git a/packages/webapp/public/favicon.ico b/packages/webapp/public/favicon.ico
new file mode 100644
index 0000000..f1fe505
Binary files /dev/null and b/packages/webapp/public/favicon.ico differ
diff --git a/packages/webapp/src/api.ts b/packages/webapp/src/api.ts
new file mode 100644
index 0000000..4c8fb2e
--- /dev/null
+++ b/packages/webapp/src/api.ts
@@ -0,0 +1,75 @@
+import { type ChatResponse, type ChatRequestOptions, type ChatResponseChunk } from './models.js';
+
+export const apiBaseUrl = import.meta.env.VITE_BACKEND_API_URI || '';
+
+export async function getCompletion(options: ChatRequestOptions) {
+ const apiUrl = options.apiUrl || apiBaseUrl;
+ const response = await fetch(`${apiUrl}/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ messages: options.messages,
+ stream: options.stream,
+ context: {
+ top: options.top,
+ temperature: options.temperature,
+ },
+ }),
+ });
+
+ if (options.stream) {
+ return getChunksFromResponse(response, options.chunkIntervalMs);
+ }
+
+ const json: ChatResponse = await response.json();
+ if (response.status > 299 || !response.ok) {
+ throw new Error(json.error ?? 'Unknown error');
+ }
+
+ return json;
+}
+
+export function getCitationUrl(citation: string): string {
+ return `${apiBaseUrl}/content/${citation}`;
+}
+
+export class NdJsonParserStream extends TransformStream {
+ private buffer = '';
+ constructor() {
+ let controller: TransformStreamDefaultController;
+ super({
+ start(_controller) {
+ controller = _controller;
+ },
+ transform: (chunk) => {
+ const jsonChunks = chunk.split('\n').filter(Boolean);
+ for (const jsonChunk of jsonChunks) {
+ try {
+ this.buffer += jsonChunk;
+ controller.enqueue(JSON.parse(this.buffer));
+ this.buffer = '';
+ } catch {
+ // Invalid JSON, wait for next chunk
+ }
+ }
+ },
+ });
+ }
+}
+
+export async function* getChunksFromResponse(response: Response, intervalMs: number): AsyncGenerator {
+ const reader = response.body?.pipeThrough(new TextDecoderStream()).pipeThrough(new NdJsonParserStream()).getReader();
+ if (!reader) {
+ throw new Error('No response body or body is not readable');
+ }
+
+ let value: JSON | undefined;
+ let done: boolean;
+ while ((({ value, done } = await reader.read()), !done)) {
+ yield new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(value as T);
+ }, intervalMs);
+ });
+ }
+}
diff --git a/packages/webapp/src/components/chat.ts b/packages/webapp/src/components/chat.ts
new file mode 100644
index 0000000..2f8e914
--- /dev/null
+++ b/packages/webapp/src/components/chat.ts
@@ -0,0 +1,804 @@
+/* eslint-disable unicorn/template-indent */
+import { LitElement, css, html, nothing } from 'lit';
+import { map } from 'lit/directives/map.js';
+import { repeat } from 'lit/directives/repeat.js';
+import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
+import { customElement, property, state, query } from 'lit/decorators.js';
+import {
+ type ChatRequestOptions,
+ type ChatResponse,
+ type ChatMessage,
+ type ChatResponseChunk,
+ type ChatDebugDetails,
+ type ChatMessageContext,
+} from '../models.js';
+import { getCitationUrl, getCompletion } from '../api.js';
+import { type ParsedMessage, parseMessageIntoHtml } from '../message-parser.js';
+import sendSvg from '../../assets/send.svg?raw';
+import questionSvg from '../../assets/question.svg?raw';
+import lightbulbSvg from '../../assets/lightbulb.svg?raw';
+import newChatSvg from '../../assets/new-chat.svg?raw';
+import './debug.js';
+
+export type ChatComponentState = {
+ hasError: boolean;
+ isLoading: boolean;
+ isStreaming: boolean;
+};
+
+export type ChatComponentOptions = ChatRequestOptions & {
+ enablePromptSuggestions: boolean;
+ enableContentLinks: boolean;
+ promptSuggestions: string[];
+ apiUrl?: string;
+ strings: {
+ promptSuggestionsTitle: string;
+ citationsTitle: string;
+ followUpQuestionsTitle: string;
+ showThoughtProcessTitle: string;
+ closeTitle: string;
+ thoughtsTitle: string;
+ supportingContentTitle: string;
+ chatInputPlaceholder: string;
+ chatInputButtonLabel: string;
+ assistant: string;
+ user: string;
+ errorMessage: string;
+ newChatButton: string;
+ retryButton: string;
+ };
+};
+
+export const defaultOptions: ChatComponentOptions = {
+ enableContentLinks: false,
+ stream: false,
+ chunkIntervalMs: 30,
+ apiUrl: '',
+ enablePromptSuggestions: true,
+ promptSuggestions: [
+ 'How to search and book rentals?',
+ 'What is the refund policy?',
+ 'How to contact a representative?',
+ ],
+ messages: [],
+ strings: {
+ promptSuggestionsTitle: 'Ask anything or try an example',
+ citationsTitle: 'Citations:',
+ followUpQuestionsTitle: 'Follow-up questions:',
+ showThoughtProcessTitle: 'Show thought process',
+ closeTitle: 'Close',
+ thoughtsTitle: 'Thought process',
+ supportingContentTitle: 'Supporting Content',
+ chatInputPlaceholder: 'Ask me anything...',
+ chatInputButtonLabel: 'Send question',
+ assistant: 'Support Assistant',
+ user: 'You',
+ errorMessage: 'We are currently experiencing an issue.',
+ newChatButton: 'New chat',
+ retryButton: 'Retry',
+ },
+};
+
+/**
+ * A chat component that allows the user to ask questions and get answers from an API.
+ * The component also displays default prompts that the user can click on to ask a question.
+ * The component is built as a custom element that extends LitElement.
+ *
+ * Labels and other aspects are configurable via the `option` property.
+ * @element azc-chat
+ * @fires messagesUpdated - Fired when the message thread is updated
+ * @fires stateChanged - Fired when the state of the component changes
+ * */
+@customElement('azc-chat')
+export class ChatComponent extends LitElement {
+ @property({
+ type: Object,
+ converter: (value) => ({ ...defaultOptions, ...JSON.parse(value || '{}') }),
+ })
+ options: ChatComponentOptions = defaultOptions;
+
+ @property() question = '';
+ @property({ type: Array }) messages: ChatMessage[] = [];
+ @state() protected hasError = false;
+ @state() protected isLoading = false;
+ @state() protected isStreaming = false;
+ @state() protected debugDetails?: ChatDebugDetails;
+ @query('.messages') protected messagesElement;
+ @query('.chat-input') protected chatInputElement;
+
+ onSuggestionClicked(suggestion: string) {
+ this.question = suggestion;
+ this.onSendClicked();
+ }
+
+ onCitationClicked(citation: string) {
+ if (this.options.enableContentLinks) {
+ const path = getCitationUrl(citation);
+ window.open(path, '_blank');
+ } else {
+ // TODO: open debug details
+ }
+ }
+
+ onKeyPressed(event: KeyboardEvent) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this.onSendClicked();
+ }
+ }
+
+ onShowDebugClicked(context: ChatMessageContext = {}) {
+ this.debugDetails = {
+ thoughts: context.thoughts ?? '',
+ dataPoints: context.data_points ?? [],
+ };
+ }
+
+ async onSendClicked(isRetry = false) {
+ if (this.isLoading) {
+ return;
+ }
+
+ this.hasError = false;
+ if (!isRetry) {
+ this.messages = [
+ ...this.messages,
+ {
+ content: this.question,
+ role: 'user',
+ },
+ ];
+ }
+
+ this.question = '';
+ this.isLoading = true;
+ this.scrollToLastMessage();
+ try {
+ const response = await getCompletion({ ...this.options, messages: this.messages });
+ if (this.options.stream) {
+ this.isStreaming = true;
+ const chunks = response as AsyncGenerator;
+ const { messages } = this;
+ const message: ChatMessage = {
+ content: '',
+ role: 'assistant',
+ context: {
+ data_points: [],
+ thoughts: '',
+ },
+ };
+ for await (const chunk of chunks) {
+ if (chunk.choices[0].delta.context?.data_points) {
+ message.context!.data_points = chunk.choices[0].delta.context?.data_points;
+ message.context!.thoughts = chunk.choices[0].delta.context?.thoughts ?? '';
+ } else if (chunk.choices[0].delta.content) {
+ message.content += chunk.choices[0].delta.content;
+ this.messages = [...messages, message];
+ this.scrollToLastMessage();
+ }
+ }
+ } else {
+ const chatResponse = response as ChatResponse;
+ this.messages = [...this.messages, chatResponse.choices[0].message];
+ this.scrollToLastMessage();
+ }
+
+ this.isLoading = false;
+ this.isStreaming = false;
+ } catch (error) {
+ this.hasError = true;
+ this.isLoading = false;
+ this.isStreaming = false;
+ console.error(error);
+ }
+ }
+
+ override requestUpdate(name?: string, oldValue?: any) {
+ if (name === 'messages') {
+ const messagesUpdatedEvent = new CustomEvent('messagesUpdated', {
+ detail: { messages: this.messages },
+ bubbles: true,
+ });
+ this.dispatchEvent(messagesUpdatedEvent);
+ } else if (name === 'hasError' || name === 'isLoading' || name === 'isStreaming') {
+ const state = {
+ hasError: this.hasError,
+ isLoading: this.isLoading,
+ isStreaming: this.isStreaming,
+ };
+ const stateUpdatedEvent = new CustomEvent('stateChanged', {
+ detail: { state },
+ bubbles: true,
+ });
+ this.dispatchEvent(stateUpdatedEvent);
+ }
+
+ return super.requestUpdate(name, oldValue);
+ }
+
+ protected scrollToLastMessage() {
+ // Need to be delayed to run after the DOM refresh
+ setTimeout(() => {
+ const { bottom } = this.messagesElement.getBoundingClientRect();
+ const { top } = this.chatInputElement.getBoundingClientRect();
+ if (bottom > top) {
+ window.scrollBy(0, bottom - top);
+ }
+ }, 0);
+ }
+
+ protected renderSuggestions = (suggestions: string[]) => html`
+
+