diff --git a/docs/chat-ui.md b/docs/chat-ui.md new file mode 100644 index 0000000..97c5118 --- /dev/null +++ b/docs/chat-ui.md @@ -0,0 +1,49 @@ +# 🤖 AI Chat UI: Reusable Chat Web Component + +As part of the [AI Chat Protocol SDK](/sdk), we provide a Chat UI [Web Component](https://developer.mozilla.org/docs/Web/Web_Components) that can be easily integrated into your application and can be used with any modern web framework or plain HTML. + +## Usage + +### Importing directly in the browser + +In your HTML file, you can import the web component directly from the CDN: + +```html + +``` + +This will make the `mai-chat` web component available in your HTML code. + +```html + +``` + +### Using a build system + +Once the package is installed, you can use the web component in your HTML code or template like this: + +```html + +``` + +Depending of the framework and build system you're using, you'll have to import the web component in your JS code in different ways. You can have a look at the various integrations examples here: + +- [Vanilla HTML](../samples/frontend/js/wc-html) +- [Angular](../wc-angular) +- [React](../wc-react) +- [Vue](../wc-vue) +- [Svelte](../wc-svelte) + +### Configuration + +### `mai-chat` web component + +This web component is used to display the chat interface. It can be used with or without the `mai-auth` component. + +#### Attributes + +- `options`: JSON options to configure the chat component. See [ChatComponentOptions](../sdk/js/packages/client/src/ui/chat.ts#L28) for more details. +- `question`: Initial question to display in the chat. +- `messages`: Array of [messages](https://github.com/microsoft/ai-chat-protocol) to display in the chat. + +By default, the component expect the [Chat API implementation](https://github.com/microsoft/ai-chat-protocol) to be available at `/api/chat`. You can change this URL by setting the `options.apiUrl` property. diff --git a/sdk/js/packages/client/assets/icons/new-chat.svg b/sdk/js/packages/client/assets/icons/new-chat.svg new file mode 100644 index 0000000..7f47869 --- /dev/null +++ b/sdk/js/packages/client/assets/icons/new-chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sdk/js/packages/client/assets/icons/question.svg b/sdk/js/packages/client/assets/icons/question.svg new file mode 100644 index 0000000..229b59d --- /dev/null +++ b/sdk/js/packages/client/assets/icons/question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sdk/js/packages/client/assets/icons/send.svg b/sdk/js/packages/client/assets/icons/send.svg new file mode 100644 index 0000000..fab2fc8 --- /dev/null +++ b/sdk/js/packages/client/assets/icons/send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sdk/js/packages/client/package-lock.json b/sdk/js/packages/client/package-lock.json index 95580cb..62da257 100644 --- a/sdk/js/packages/client/package-lock.json +++ b/sdk/js/packages/client/package-lock.json @@ -19,11 +19,14 @@ "@types/node": "^20.11.20", "@vitest/browser": "^1.5.0", "eslint": "^8.57.0", + "lit": "^3.2.1", + "lit-analyzer": "^2.0.3", "playwright": "^1.43.1", "prettier": "^3.2.5", "rimraf": "^5.0.5", "rollup": "^4.17.2", "rollup-plugin-dts": "^6.1.0", + "rollup-plugin-svg-import": "^3.0.0", "rollup-plugin-typescript2": "^0.36.0", "tslib": "^2.6.2", "typescript": "^5.4.5", @@ -149,6 +152,19 @@ "node": ">=4" } }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -668,6 +684,23 @@ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", "dev": true }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", + "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz", + "integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1072,6 +1105,13 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.10.0.tgz", @@ -1435,6 +1475,13 @@ "@types/estree": "^1.0.0" } }, + "node_modules/@vscode/web-custom-data": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@vscode/web-custom-data/-/web-custom-data-0.4.12.tgz", + "integrity": "sha512-bCemuvwCC84wJQbJoaPou86sjz9DUvZgGa6sAWQwzw7oIELD7z+WnUj2Rdsu8/8XPhKLcg3IswQ2+Pm3OMinIg==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -1644,6 +1691,61 @@ "node": "*" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1737,6 +1839,21 @@ "node": ">=0.10.0" } }, + "node_modules/didyoumean2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/didyoumean2/-/didyoumean2-4.1.0.tgz", + "integrity": "sha512-qTBmfQoXvhKO75D/05C8m+fteQmn4U46FWYiLhXtZQInzitXLWY0EQ/2oKnpAz9g2lQWW8jYcLcT+hPJGT+kig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.2", + "leven": "^3.1.0", + "lodash.deburr": "^4.1.0" + }, + "engines": { + "node": ">=10.13" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -1820,6 +1937,16 @@ "@esbuild/win32-x64": "0.20.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2229,6 +2356,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -2646,6 +2783,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2659,6 +2806,139 @@ "node": ">= 0.8.0" } }, + "node_modules/lit": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz", + "integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.0.4", + "lit-element": "^4.1.0", + "lit-html": "^3.2.0" + } + }, + "node_modules/lit-analyzer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/lit-analyzer/-/lit-analyzer-2.0.3.tgz", + "integrity": "sha512-XiAjnwVipNrKav7r3CSEZpWt+mwYxrhPRVC7h8knDmn/HWTzzWJvPe+mwBcL2brn4xhItAMzZhFC8tzzqHKmiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/web-custom-data": "^0.4.2", + "chalk": "^2.4.2", + "didyoumean2": "4.1.0", + "fast-glob": "^3.2.11", + "parse5": "5.1.0", + "ts-simple-type": "~2.0.0-next.0", + "vscode-css-languageservice": "4.3.0", + "vscode-html-languageservice": "3.1.0", + "web-component-analyzer": "^2.0.0" + }, + "bin": { + "lit-analyzer": "cli.js" + } + }, + "node_modules/lit-analyzer/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lit-analyzer/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lit-analyzer/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/lit-analyzer/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lit-analyzer/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/lit-analyzer/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/lit-analyzer/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lit-element": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.1.tgz", + "integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0", + "@lit/reactive-element": "^2.0.4", + "lit-html": "^3.2.0" + } + }, + "node_modules/lit-html": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz", + "integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/local-pkg": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", @@ -2690,6 +2970,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.deburr": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz", + "integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2977,6 +3264,13 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", + "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3286,6 +3580,23 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -3443,6 +3754,22 @@ "typescript": "^4.5 || ^5.0" } }, + "node_modules/rollup-plugin-svg-import": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-svg-import/-/rollup-plugin-svg-import-3.0.0.tgz", + "integrity": "sha512-5fUESTM5hdqJojrwO53JQUO7NespLNx4iLeMsToQfuaGGqGT5sz85Ns5gCDNxLO6yBPbn7p0A/6YA+Rq3clg4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "rollup": "^3.0.0||^4.0.0" + } + }, "node_modules/rollup-plugin-typescript2": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz", @@ -3813,6 +4140,13 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-simple-type": { + "version": "2.0.0-next.0", + "resolved": "https://registry.npmjs.org/ts-simple-type/-/ts-simple-type-2.0.0-next.0.tgz", + "integrity": "sha512-A+hLX83gS+yH6DtzNAhzZbPfU+D9D8lHlTSd7GeoMRBjOt3GRylDqLTYbdmjA4biWvq2xSfpqfIDj2l0OA/BVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -4076,6 +4410,91 @@ } } }, + "node_modules/vscode-css-languageservice": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-4.3.0.tgz", + "integrity": "sha512-BkQAMz4oVHjr0oOAz5PdeE72txlLQK7NIwzmclfr+b6fj6I8POwB+VoXvrZLTbWt9hWRgfvgiQRkh5JwrjPJ5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "3.16.0-next.2", + "vscode-nls": "^4.1.2", + "vscode-uri": "^2.1.2" + } + }, + "node_modules/vscode-html-languageservice": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-3.1.0.tgz", + "integrity": "sha512-QAyRHI98bbEIBCqTzZVA0VblGU40na0txggongw5ZgTj9UVsVk5XbLT16O9OTcbqBGSqn0oWmFDNjK/XGIDcqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "3.16.0-next.2", + "vscode-nls": "^4.1.2", + "vscode-uri": "^2.1.2" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.16.0-next.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0-next.2.tgz", + "integrity": "sha512-QjXB7CKIfFzKbiCJC4OWC8xUncLsxo19FzGVp/ADFvvi87PlmBSCAtZI5xwGjF5qE0xkLf0jjKUn3DzmpDP52Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-nls": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-4.1.2.tgz", + "integrity": "sha512-7bOHxPsfyuCqmP+hZXscLhiHwe7CSuFE4hyhbs22xPIhQ4jv99FcR4eBzfYYVLP356HNFpdvz63FFb/xw6T4Iw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", + "integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-component-analyzer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/web-component-analyzer/-/web-component-analyzer-2.0.0.tgz", + "integrity": "sha512-UEvwfpD+XQw99sLKiH5B1T4QwpwNyWJxp59cnlRwFfhUW6JsQpw5jMeMwi7580sNou8YL3kYoS7BWLm+yJ/jVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.2", + "ts-simple-type": "2.0.0-next.0", + "typescript": "~5.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "wca": "cli.js", + "web-component-analyzer": "cli.js" + } + }, + "node_modules/web-component-analyzer/node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4216,6 +4635,67 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/sdk/js/packages/client/package.json b/sdk/js/packages/client/package.json index 99b1fe5..c257c22 100644 --- a/sdk/js/packages/client/package.json +++ b/sdk/js/packages/client/package.json @@ -5,7 +5,7 @@ "main": "./dist/commonjs/index.js", "scripts": { "build": "rollup -c", - "lint": "eslint src", + "lint": "eslint src && lit-analyzer", "format": "prettier --write src test", "check-format": "prettier --check src test", "clean": "rimraf dist", @@ -33,11 +33,14 @@ "@types/node": "^20.11.20", "@vitest/browser": "^1.5.0", "eslint": "^8.57.0", + "lit": "^3.2.1", + "lit-analyzer": "^2.0.3", "playwright": "^1.43.1", "prettier": "^3.2.5", "rimraf": "^5.0.5", "rollup": "^4.17.2", "rollup-plugin-dts": "^6.1.0", + "rollup-plugin-svg-import": "^3.0.0", "rollup-plugin-typescript2": "^0.36.0", "tslib": "^2.6.2", "typescript": "^5.4.5", diff --git a/sdk/js/packages/client/rollup.config.js b/sdk/js/packages/client/rollup.config.js index 21005a7..6ce92a8 100644 --- a/sdk/js/packages/client/rollup.config.js +++ b/sdk/js/packages/client/rollup.config.js @@ -3,6 +3,7 @@ import dts from "rollup-plugin-dts"; import typescript from "@rollup/plugin-typescript"; import nodeResolve from "@rollup/plugin-node-resolve"; import alias from "@rollup/plugin-alias"; +import svg from 'rollup-plugin-svg-import'; export default [ { @@ -20,6 +21,7 @@ export default [ }, ], plugins: [ + svg({ stringify: true }), nodeResolve({ preferBuiltins: true, }), @@ -43,6 +45,7 @@ export default [ }, ], plugins: [ + svg({ stringify: true }), alias({ entries: [ { diff --git a/sdk/js/packages/client/src/index.ts b/sdk/js/packages/client/src/index.ts index 8449508..6388c71 100644 --- a/sdk/js/packages/client/src/index.ts +++ b/sdk/js/packages/client/src/index.ts @@ -15,3 +15,4 @@ export { AIChatError, AIChatErrorResponse, } from "./model/index.js"; +export * from "./ui/index.js"; \ No newline at end of file diff --git a/sdk/js/packages/client/src/ui/api.ts b/sdk/js/packages/client/src/ui/api.ts new file mode 100644 index 0000000..e0203ae --- /dev/null +++ b/sdk/js/packages/client/src/ui/api.ts @@ -0,0 +1,44 @@ +import { AIChatProtocolClient } from "../client.js"; +import { + AIChatMessage, + AIChatCompletionDelta, + AIChatCompletion, +} from "../model/index.js"; + +export type ChatRequestOptions = { + messages: AIChatMessage[]; + chunkIntervalMs: number; + apiUrl: string; + stream: boolean; +}; + +export async function getCompletion( + options: ChatRequestOptions, +): Promise> { + const apiUrl = options.apiUrl || ""; + const client = new AIChatProtocolClient(`${apiUrl}/api/chat`); + + if (options.stream) { + const response = await client.getStreamedCompletion(options.messages); + return getChunksFromResponse(response, options.chunkIntervalMs); + } else { + return await client.getCompletion(options.messages); + } +} + +export async function* getChunksFromResponse( + response: AsyncIterable, + intervalMs: number, +): AsyncGenerator { + for await (const chunk of response) { + if (!chunk.delta) { + continue; + } + + yield new Promise((resolve) => { + setTimeout(() => { + resolve(chunk); + }, intervalMs); + }); + } +} diff --git a/sdk/js/packages/client/src/ui/chat.ts b/sdk/js/packages/client/src/ui/chat.ts new file mode 100644 index 0000000..ac6b8d8 --- /dev/null +++ b/sdk/js/packages/client/src/ui/chat.ts @@ -0,0 +1,774 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// + +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 { + AIChatCompletion, + AIChatCompletionDelta, + AIChatMessage, +} from "../model/index.js"; +import { type ChatRequestOptions, getCompletion } from "./api.js"; +import { type ParsedMessage, parseMessageIntoHtml } from "./message-parser.js"; +import sendSvg from "../../assets/icons/send.svg"; +import questionSvg from "../../assets/icons/question.svg"; +import newChatSvg from "../../assets/icons/new-chat.svg"; + +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; + supportingContentTitle: string; + chatInputPlaceholder: string; + chatInputButtonLabel: string; + assistant: string; + user: string; + errorMessage: string; + newChatButton: string; + retryButton: string; + }; + onCitationClicked?: (citation: string) => void; +}; + +export const chatDefaultOptions: ChatComponentOptions = { + enableContentLinks: false, + stream: true, + chunkIntervalMs: 30, + apiUrl: "", + enablePromptSuggestions: true, + promptSuggestions: [ + "How to make chocolate cookies?", + "What ingredients do I need for a banana cake?", + "Is bread gluten-free?", + ], + messages: [], + strings: { + promptSuggestionsTitle: "Ask anything or try an example", + citationsTitle: "Citations:", + followUpQuestionsTitle: "Follow-up questions:", + 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", + }, + onCitationClicked: () => {}, +}; + +/** + * 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 mai-chat + * @fires messagesUpdated - Fired when the message thread is updated + * @fires stateChanged - Fired when the state of the component changes + * */ +@customElement("mai-chat") +export class ChatComponent extends LitElement { + @property({ + type: Object, + converter: (value) => ({ + ...chatDefaultOptions, + ...JSON.parse(value || "{}"), + }), + }) + options: ChatComponentOptions = chatDefaultOptions; + + @property() question = ""; + @property({ type: Array }) messages: AIChatMessage[] = []; + @state() protected hasError = false; + @state() protected isLoading = false; + @state() protected isStreaming = false; + @query(".messages") protected messagesElement!: HTMLElement; + @query(".chat-input") protected chatInputElement!: HTMLElement; + + onSuggestionClicked(suggestion: string) { + this.question = suggestion; + this.onSendClicked(); + } + + onCitationClicked(citation: string) { + if (this.options.enableContentLinks) { + this.options.onCitationClicked?.(citation); + } + } + + onKeyPressed(event: KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + this.onSendClicked(); + } + } + + 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: AIChatMessage = { + content: "", + role: "assistant", + }; + for await (const chunk of chunks) { + if (chunk.delta.content) { + this.isStreaming = true; + message.content += chunk.delta.content; + this.messages = [...messages, message]; + this.scrollToLastMessage(); + } + } + } else { + const chatResponse = response as AIChatCompletion; + this.messages = [...this.messages, chatResponse.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[]) => { + return html` +
+

${this.options.strings.promptSuggestionsTitle}

+
+ ${map( + suggestions, + (suggestion) => html` + + `, + )} +
+
+ `; + }; + + protected renderLoader = () => { + return this.isLoading && !this.isStreaming + ? html` +
+
+
+
${this.options.strings.assistant}
+
+
+ ` + : nothing; + }; + + protected renderMessage = (message: ParsedMessage) => { + return html` +
+ ${message.role === "assistant" + ? html`` + : nothing} +
+
${message.html}
+ ${message.citations.length > 0 + ? html` +
+
+ ${this.options.strings.citationsTitle} +
+ ${map(message.citations, this.renderCitation)} +
+ ` + : nothing} +
+
+ ${message.role === "user" + ? this.options.strings.user + : this.options.strings.assistant} +
+
+ `; + }; + + protected renderError = () => html` +
+
+ ${this.options.strings.errorMessage} + +
+
+ `; + + protected renderCitation = (citation: string, index: number) => + html``; + + protected renderCitationReference = (_citation: string, index: number) => + html`[${index}]`; + + protected renderFollowupQuestions = (questions: string[]) => { + return questions.length > 0 + ? html` +
+ + ${unsafeSVG(questionSvg)} ${map( + questions, + (question) => html` + + `, + )} +
+ ` + : nothing; + }; + + protected renderChatInput = () => { + return html` +
+ +
+ + +
+
+ `; + }; + + protected override render() { + const parsedMessages = this.messages.map((message) => + parseMessageIntoHtml(message, this.renderCitationReference), + ); + return html` +
+ ${this.options.enablePromptSuggestions && + this.options.promptSuggestions.length > 0 && + this.messages.length === 0 + ? this.renderSuggestions(this.options.promptSuggestions) + : nothing} +
+ ${repeat(parsedMessages, (_, index) => index, this.renderMessage)} + ${this.renderLoader()} ${this.hasError ? this.renderError() : nothing} + ${this.renderFollowupQuestions( + parsedMessages.at(-1)?.followupQuestions ?? [], + )} +
+ ${this.renderChatInput()} +
+ `; + } + + static override styles = css` + :host { + /* Base properties */ + --primary: var(--mai-primary, #07f); + --error: var(--mai-error, #e30); + --text-color: var(--mai-text-color, #000); + --text-invert-color: var(--mai--text-invert-color, #fff); + --disabled-color: var(--mai-disabled-color, #ccc); + --bg: var(--mai-bg, #eee); + --card-bg: var(--mai-card-bg, #fff); + --card-shadow: var( + --mai-card-shadow, + 0 0.3px 0.9px rgba(0 0 0 / 12%), + 0 1.6px 3.6px rgba(0 0 0 / 16%) + ); + --space-md: var(--mai-space-md, 12px); + --space-xl: var(--mai-space-xl, calc(var(--space-md) * 2)); + --space-xs: var(--mai-space-xs, calc(var(--space-md) / 2)); + --space-xxs: var(--mai-space-xs, calc(var(--space-md) / 4)); + --border-radius: var(--mai-border-radius, 16px); + --focus-outline: var(--mai-focus-outline, 2px solid); + --overlay-color: var(--mai-overlay-color, rgba(0 0 0 / 40%)); + + /* Component-specific properties */ + --error-color: var(--mai-error-color, var(--error)); + --error-border: var(--mai-error-border, none); + --error-bg: var(--mai-error-bg, var(--card-bg)); + --retry-button-color: var(--mai-retry-button-color, var(--text-color)); + --retry-button-bg: var(--mai-retry-button-bg, #f0f0f0); + --retry-button-bg-hover: var(--mai-retry-button-bg, #e5e5e5); + --retry-button-border: var(--mai-retry-button-border, none); + --suggestion-color: var(--mai-suggestion-color, var(--text-color)); + --suggestion-border: var(--mai-suggestion-border, none); + --suggestion-bg: var(--mai-suggestion-bg, var(--card-bg)); + --suggestion-shadow: var( + --mai-suggestion-shadow, + 0 6px 16px -1.5px rgba(141 141 141 / 30%) + ); + --user-message-color: var( + --mai-user-message-color, + var(--text-invert-color) + ); + --user-message-border: var(--mai-user-message-border, none); + --user-message-bg: var(--mai-user-message-bg, var(--primary)); + --bot-message-color: var(--mai-bot-message-color, var(--text-color)); + --bot-message-border: var(--mai-bot-message-border, none); + --citation-color: var(--mai-citation-color, var(--text-invert-color)); + --bot-message-bg: var(--mai-bot-message-bg, var(--card-bg)); + --citation-bg: var(--mai-citation-bg, var(--primary)); + --citation-bg-hover: var( + --mai-citation-bg, + color-mix(in srgb, var(--primary), #000 10%) + ); + --new-chat-button-color: var( + --mai-button-color, + var(--text-invert-color) + ); + --new-chat-button-bg: var(--mai-new-chat-button-bg, var(--primary)); + --new-chat-button-bg-hover: var( + --mai-new-chat-button-bg, + color-mix(in srgb, var(--primary), #000 10%) + ); + --chat-input-color: var(--mai-chat-input-color, var(--text-color)); + --chat-input-border: var(--mai-chat-input-border, none); + --chat-input-bg: var(--mai-chat-input-bg, var(--card-bg)); + --submit-button-color: var(--mai-button-color, var(--primary)); + --submit-button-border: var(--mai-submit-button-border, none); + --submit-button-bg: var(--mai-submit-button-bg, none); + --submit-button-bg-hover: var(--mai-submit-button-color, #f0f0f0); + } + *:focus-visible { + outline: var(--focus-outline) var(--primary); + } + .animation { + animation: 0.3s ease; + } + svg { + fill: currentColor; + width: 100%; + } + button { + font-size: 1rem; + border-radius: calc(var(--border-radius) / 2); + outline: var(--focus-outline) transparent; + transition: outline 0.3s ease; + + &:not(:disabled) { + cursor: pointer; + } + } + .chat-container { + container-type: inline-size; + position: relative; + background: var(--bg); + font-family: + "Segoe UI", + -apple-system, + BlinkMacSystemFont, + Roboto, + "Helvetica Neue", + sans-serif; + } + .citation { + font-size: 0.85rem; + color: var(--citation-color); + background: var(--citation-bg); + border: var(--citation-border); + padding: var(--space-xxs) var(--space-xs); + margin-right: var(--space-xs); + margin-top: var(--space-xs); + + &:hover { + background: var(--citation-bg-hover); + } + } + .citations-title { + font-weight: bold; + } + .suggestions-container { + text-align: center; + padding: var(--space-xl); + } + .suggestions { + display: flex; + gap: var(--space-md); + } + @container (width < 480px) { + .suggestions { + flex-direction: column; + } + } + + .suggestion { + flex: 1 1 0; + padding: var(--space-xl) var(--space-md); + color: var(--sugestion-color); + background: var(--suggestion-bg); + border: var(--suggestion-border); + border-radius: var(--border-radius); + box-shadow: var(--suggestion-shadow); + + &:hover { + outline: var(--focus-outline) var(--primary); + } + } + .messages { + padding: var(--space-xl); + display: flex; + flex-direction: column; + gap: var(--space-md); + } + .user { + align-self: end; + color: var(--user-message-color); + background: var(--user-message-bg); + border: var(--user-message-border); + } + .assistant { + color: var(--bot-message-color); + background: var(--bot-message-bg); + border: var(--bot-message-border); + box-shadow: var(--card-shadow); + } + .message { + position: relative; + width: auto; + max-width: 70%; + border-radius: var(--border-radius); + padding: var(--space-xl); + margin-bottom: var(--space-xl); + &.user { + animation-name: fade-in-up; + } + } + .message-body { + display: flex; + flex-direction: column; + gap: var(--space-md); + } + .content { + white-space: pre-line; + } + .message-role { + position: absolute; + right: var(--space-xl); + bottom: -1.25em; + color: var(--text-color); + font-size: 0.85rem; + opacity: 0.6; + } + .questions { + margin: var(--space-md) 0; + color: var(--primary); + text-align: right; + } + .question-icon { + vertical-align: middle; + display: inline-block; + height: 1.7rem; + width: 1.7rem; + margin-bottom: var(--space-xs); + margin-left: var(--space-xs); + } + .question { + position: relative; + padding: var(--space-xs) var(--space-md); + margin-bottom: var(--space-xs); + margin-left: var(--space-xs); + vertical-align: middle; + color: var(--primary); + background: var(--card-bg); + border: 1px solid var(--primary); + animation-name: fade-in-right; + &:hover { + background: color-mix(in srgb, var(--card-bg), var(--primary) 5%); + } + } + .button, + .submit-button { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-xs); + border: var(--button-border); + background: var(--submit-button-bg); + color: var(--submit-button-color); + &:disabled { + color: var(--disabled-color); + } + &:hover:not(:disabled) { + background: var(--submit-button-bg-hover); + } + } + .submit-button { + padding: 0; + width: 48px; + } + .close-button { + position: absolute; + top: var(--space-md); + right: var(--space-md); + width: auto; + padding: var(--space-md); + &:hover:not(:disabled) { + background: var(--card-bg); + } + } + .error { + color: var(--error-color); + background: var(--error-bg); + outline: var(--focus-outline) var(--error); + + & .message-body { + flex-direction: row; + align-items: center; + } + + & button { + flex: 0; + padding: var(--space-md); + color: var(--retry-button-color); + background: var(--retry-button-bg); + border: var(--retry-button-border); + + &:hover { + background: var(--retry-button-bg-hover); + } + } + } + .error-message { + flex: 1; + } + .chat-input { + --half-space-xl: calc(var(--space-xl) / 2); + position: sticky; + bottom: 0; + padding: var(--space-xl); + padding-top: var(--half-space-xl); + background: var(--bg); + box-shadow: 0 calc(-1 * var(--half-space-xl)) var(--half-space-xl) + var(--bg); + display: flex; + gap: var(--space-md); + } + .new-chat-button { + width: 48px; + height: 48px; + padding: var(--space-md); + border-radius: 50%; + background: var(--new-chat-button-bg); + color: var(--new-chat-button-color); + font-size: 1.5rem; + &:hover:not(:disabled) { + background: var(--new-chat-button-bg-hover); + color: var(--new-chat-button-color); + } + } + .input-form { + display: flex; + flex: 1 auto; + background: var(--chat-input-bg); + border: var(--chat-input-border); + border-radius: var(--border-radius); + padding: var(--space-md); + box-shadow: var(--card-shadow); + outline: var(--focus-outline) transparent; + transition: outline 0.3s ease; + + &:has(.text-input:focus-visible) { + outline: var(--focus-outline) var(--primary); + } + } + .text-input { + padding: var(--space-xs); + font-family: inherit; + font-size: 1rem; + flex: 1 auto; + height: 3rem; + border: none; + resize: none; + background: none; + &::placeholder { + color: var(--text-color); + opacity: 0.4; + } + &:focus { + outline: none; + } + &:disabled { + opacity: 0.7; + } + } + .loader-animation { + width: 100px; + height: 4px; + border-radius: var(--border-radius); + overflow: hidden; + background-color: var(--primary); + transform: scaleX(0); + transform-origin: center left; + animation: cubic-bezier(0.85, 0, 0.15, 1) 2s infinite load-animation; + } + + @keyframes load-animation { + 0% { + transform: scaleX(0); + transform-origin: center left; + } + 50% { + transform: scaleX(1); + transform-origin: center left; + } + 51% { + transform: scaleX(1); + transform-origin: center right; + } + 100% { + transform: scaleX(0); + transform-origin: center right; + } + } + @keyframes fade-in-up { + 0% { + opacity: 0.5; + top: 100px; + } + 100% { + opacity: 1; + top: 0px; + } + } + @keyframes fade-in-right { + 0% { + opacity: 0.5; + right: -100px; + } + 100% { + opacity: 1; + right: 0; + } + } + @media (prefers-reduced-motion: reduce) { + .animation { + animation: none; + } + } + `; +} diff --git a/sdk/js/packages/client/src/ui/index.ts b/sdk/js/packages/client/src/ui/index.ts new file mode 100644 index 0000000..4e29a40 --- /dev/null +++ b/sdk/js/packages/client/src/ui/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./api.js"; +export * from "./chat.js"; diff --git a/sdk/js/packages/client/src/ui/message-parser.ts b/sdk/js/packages/client/src/ui/message-parser.ts new file mode 100644 index 0000000..4d7c83f --- /dev/null +++ b/sdk/js/packages/client/src/ui/message-parser.ts @@ -0,0 +1,71 @@ +import { type HTMLTemplateResult, html, nothing } from "lit"; +import { AIChatMessage } from "../model/index.js"; + +export type ParsedMessage = { + html: HTMLTemplateResult; + citations: string[]; + followupQuestions: string[]; + role: string; + context?: object; +}; + +export function parseMessageIntoHtml( + message: AIChatMessage, + renderCitationReference: ( + citation: string, + index: number, + ) => HTMLTemplateResult, +): ParsedMessage { + if (message.role === "user") { + return { + html: html`${message.content}`, + citations: [], + followupQuestions: [], + role: message.role, + context: message.context, + }; + } + + const citations: string[] = []; + const followupQuestions: string[] = []; + + // Extract any follow-up questions that might be in the message + const text = message.content + .replaceAll(/<<([^>]+)>>/g, (_match, content: string) => { + followupQuestions.push(content); + return ""; + }) + .split("<<")[0] // Truncate incomplete questions + .trim(); + + // Extract any citations that might be in the message + const parts = text.split(/\[([^\]]+)]/g); + const result = html`${parts.map((part, index) => { + if (index % 2 === 0) { + return html`${part}`; + } + + if (index + 1 < parts.length) { + // Handle only completed citations + let citationIndex = citations.indexOf(part); + if (citationIndex === -1) { + citations.push(part); + citationIndex = citations.length; + } else { + citationIndex++; + } + + return renderCitationReference(part, citationIndex); + } + + return nothing; + })}`; + + return { + html: result, + citations, + followupQuestions, + role: message.role, + context: message.context, + }; +} diff --git a/sdk/js/packages/client/tsconfig.json b/sdk/js/packages/client/tsconfig.json index 58b5078..f9f6998 100644 --- a/sdk/js/packages/client/tsconfig.json +++ b/sdk/js/packages/client/tsconfig.json @@ -13,8 +13,15 @@ "sourceMap": true, "declaration": true, "declarationMap": true, + "experimentalDecorators": true, "rootDir": ".", "outDir": "build", + "plugins": [ + { + "name": "ts-lit-plugin", + "strict": true + } + ], "lib": [ "ESNext", "DOM"